From 5d9a3fd7fb8f2f9d0f592c89446824980c9841c6 Mon Sep 17 00:00:00 2001 From: Allan Wang Date: Mon, 14 Aug 2017 20:48:39 -0700 Subject: v1.4.5 (#174) * Update/kau (#125) * Update logger * Clean imports and bring back reactive libs * Update dependencies and make billing async * Misc (#128) * Update null * Attempt to improve transparent theme backgrounds * Update menu * Move injections to visible method and reduce offset * Update searchview and logging * Clean temp strings and add network states * Move console blacklist to web state * Change some logs to info * Move glide loader to onCreate (#135) * Remove commit number increments (#139) * Fix/misc (#140) * Add canadian locale to toLowerCase * Add try catch to JsAssets * Disable error throwing for bad search subject * Log more throwables quietly * Check internet connection before fetching username * Remove name check in frost notifications * Add activity lifecycle logger * Add rxjava to lib showcase * Move network checker to io thread (#150) * Update dependency * Blank * Feature/jsoup debugger (#152) * Create debugger * Update debugger content * Create debugging logic * Finalize and test debugger * Add reload listener * Fix/pro crash without play store (#155) * Update changelog * Check if iab service exists * Add checker before launching play store request * Separate strings * Enhancement/message notifications (#157) * Map message notifs to the headless html extractor * Update strings * Bring im notifs out of alpha * Update changelog * Remove confirmation dialog (#159) * Separate message notifications and add click intents (#171) * Separate message notifications and add click intent for group notifications * Add comments and finalize * Feature/scroll down on message thread (#172) * Add hook for scroll * Update changelog * Add custom navdrawer layout (#173) * Add faq for auto play * Update changelog * Fix page banner bg (#163) --- .../main/kotlin/com/pitchedapps/frost/FrostApp.kt | 34 +++-- .../pitchedapps/frost/activities/AboutActivity.kt | 32 +++-- .../pitchedapps/frost/activities/BaseActivity.kt | 43 ++++++ .../pitchedapps/frost/activities/ImageActivity.kt | 12 +- .../pitchedapps/frost/activities/LoginActivity.kt | 19 +-- .../pitchedapps/frost/activities/MainActivity.kt | 65 ++++----- .../frost/activities/SettingsActivity.kt | 23 ++-- .../com/pitchedapps/frost/contracts/FileChooser.kt | 2 - .../com/pitchedapps/frost/dbflow/CookiesDb.kt | 16 ++- .../com/pitchedapps/frost/dbflow/FbTabsDb.kt | 8 +- .../com/pitchedapps/frost/facebook/FbItem.kt | 39 ++++++ .../kotlin/com/pitchedapps/frost/facebook/FbTab.kt | 39 ------ .../pitchedapps/frost/facebook/FbUrlFormatter.kt | 2 +- .../pitchedapps/frost/facebook/UsernameFetcher.kt | 5 +- .../com/pitchedapps/frost/fragments/WebFragment.kt | 12 +- .../com/pitchedapps/frost/injectors/CssAssets.kt | 50 ++++--- .../com/pitchedapps/frost/injectors/JsActions.kt | 3 +- .../com/pitchedapps/frost/injectors/JsAssets.kt | 14 +- .../frost/services/FrostNotifications.kt | 76 ++++++----- .../frost/services/NotificationService.kt | 105 ++++++++++---- .../pitchedapps/frost/services/UpdateReceiver.kt | 1 + .../com/pitchedapps/frost/settings/Behaviour.kt | 4 + .../kotlin/com/pitchedapps/frost/settings/Debug.kt | 151 +++++++++++++++++++++ .../com/pitchedapps/frost/settings/Experimental.kt | 14 +- .../com/pitchedapps/frost/settings/Network.kt | 17 +++ .../pitchedapps/frost/settings/Notifications.kt | 4 + .../com/pitchedapps/frost/utils/JsoupCleaner.kt | 34 +++++ .../main/kotlin/com/pitchedapps/frost/utils/L.kt | 31 ++--- .../kotlin/com/pitchedapps/frost/utils/Prefs.kt | 9 +- .../kotlin/com/pitchedapps/frost/utils/Utils.kt | 17 ++- .../com/pitchedapps/frost/utils/iab/IABBinder.kt | 102 +++++++++----- .../com/pitchedapps/frost/utils/iab/IABDialogs.kt | 9 +- .../pitchedapps/frost/web/FrostChromeClients.kt | 12 +- .../kotlin/com/pitchedapps/frost/web/FrostJSI.kt | 6 +- .../frost/web/FrostRequestInterceptor.kt | 29 ++-- .../com/pitchedapps/frost/web/FrostWebView.kt | 6 +- .../pitchedapps/frost/web/FrostWebViewClients.kt | 82 ++++++----- .../com/pitchedapps/frost/web/FrostWebViewCore.kt | 6 +- .../pitchedapps/frost/web/HeadlessHtmlExtractor.kt | 88 ++++++++++++ .../com/pitchedapps/frost/web/LoginWebView.kt | 2 +- .../com/pitchedapps/frost/web/MessageWebView.kt | 67 --------- .../com/pitchedapps/frost/web/SearchWebView.kt | 33 +++-- .../kotlin/com/pitchedapps/frost/web/WebStates.kt | 11 ++ 43 files changed, 899 insertions(+), 435 deletions(-) create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt delete mode 100644 app/src/main/kotlin/com/pitchedapps/frost/facebook/FbTab.kt create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/settings/Network.kt create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/utils/JsoupCleaner.kt create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/web/HeadlessHtmlExtractor.kt delete mode 100644 app/src/main/kotlin/com/pitchedapps/frost/web/MessageWebView.kt create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/web/WebStates.kt (limited to 'app/src/main/kotlin/com/pitchedapps') diff --git a/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt b/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt index 371f9c33..4fabf8b8 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt @@ -1,9 +1,12 @@ package com.pitchedapps.frost +import android.app.Activity import android.app.Application import android.graphics.drawable.Drawable import android.net.Uri +import android.os.Bundle import android.widget.ImageView +import ca.allanwang.kau.logging.KL import com.bumptech.glide.Glide import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.signature.ApplicationVersionSignature @@ -12,13 +15,12 @@ import com.crashlytics.android.answers.Answers import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader import com.mikepenz.materialdrawer.util.DrawerImageLoader import com.pitchedapps.frost.facebook.FbCookie -import com.pitchedapps.frost.utils.CrashReportingTree +import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.Showcase import com.raizlabs.android.dbflow.config.FlowConfig import com.raizlabs.android.dbflow.config.FlowManager import io.fabric.sdk.android.Fabric -import timber.log.Timber import java.util.* @@ -39,22 +41,18 @@ class FrostApp : Application() { Prefs.initialize(this, "${BuildConfig.APPLICATION_ID}.prefs") // if (LeakCanary.isInAnalyzerProcess(this)) return // refWatcher = LeakCanary.install(this) - if (BuildConfig.DEBUG) { - Timber.plant(Timber.DebugTree()) -// LeakCanary.enableDisplayLeakActivity(this) - } else { + if (!BuildConfig.DEBUG) { Fabric.with(this, Crashlytics(), Answers()) Crashlytics.setUserIdentifier(Prefs.frostId) - Timber.plant(CrashReportingTree()) } + KL.debug(BuildConfig.DEBUG) + L.debug(BuildConfig.DEBUG) Prefs.verboseLogging = false FbCookie() if (Prefs.installDate == -1L) Prefs.installDate = System.currentTimeMillis() if (Prefs.identifier == -1) Prefs.identifier = Random().nextInt(Int.MAX_VALUE) Prefs.lastLaunch = System.currentTimeMillis() - - super.onCreate() /** @@ -69,6 +67,24 @@ class FrostApp : Application() { .thumbnail(old).into(imageView) } }) + if (BuildConfig.DEBUG) + registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { + override fun onActivityPaused(activity: Activity) {} + override fun onActivityResumed(activity: Activity) {} + override fun onActivityStarted(activity: Activity) {} + + override fun onActivityDestroyed(activity: Activity) { + L.d("Activity ${activity.localClassName} destroyed") + } + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle?) {} + + override fun onActivityStopped(activity: Activity) {} + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + L.d("Activity ${activity.localClassName} created") + } + }) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/AboutActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/AboutActivity.kt index fbcd12cc..670e8669 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/AboutActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/AboutActivity.kt @@ -1,10 +1,8 @@ package com.pitchedapps.frost.activities -import android.os.Bundle import android.support.constraint.ConstraintLayout import android.support.constraint.ConstraintSet import android.support.v7.widget.RecyclerView -import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView @@ -13,8 +11,6 @@ import ca.allanwang.kau.about.LibraryIItem import ca.allanwang.kau.adapters.FastItemThemedAdapter import ca.allanwang.kau.adapters.ThemableIItem import ca.allanwang.kau.adapters.ThemableIItemDelegate -import ca.allanwang.kau.animators.FadeScaleAnimatorAdd -import ca.allanwang.kau.animators.KauAnimator import ca.allanwang.kau.utils.* import com.mikepenz.aboutlibraries.Libs import com.mikepenz.aboutlibraries.entity.Library @@ -26,10 +22,8 @@ import com.mikepenz.google_material_typeface_library.GoogleMaterial import com.mikepenz.iconics.typeface.IIcon import com.pitchedapps.frost.BuildConfig import com.pitchedapps.frost.R +import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs -import org.jetbrains.anko.doAsync -import org.jetbrains.anko.uiThread -import java.security.InvalidParameterException /** @@ -41,6 +35,7 @@ class AboutActivity : AboutActivityBase(null, { backgroundColor = Prefs.bgColor.withMinAlpha(200) cutoutForeground = if (0xff3b5998.toInt().isColorVisibleOn(Prefs.bgColor)) 0xff3b5998.toInt() else Prefs.accentColor cutoutDrawableRes = R.drawable.frost_f_256 + faqPageTitleRes = R.string.faq_title faqXmlRes = R.xml.frost_faq faqParseNewLine = false }) { @@ -60,6 +55,7 @@ class AboutActivity : AboutActivityBase(null, { "kotterknife", "materialdialogs", "materialdrawer", + "rxjava", "subsamplingscaleimageview" ) @@ -68,6 +64,9 @@ class AboutActivity : AboutActivityBase(null, { return l } + var lastClick = -1L + var clickCount = 0 + override fun postInflateMainPage(adapter: FastItemThemedAdapter>) { /** * Frost may not be a library but we're conveying the same info @@ -85,7 +84,22 @@ class AboutActivity : AboutActivityBase(null, { } } adapter.add(LibraryIItem(frost)).add(AboutLinks()) - + adapter.withOnClickListener { _, _, item, _ -> + if (item is LibraryIItem) { + val now = System.currentTimeMillis() + if (now - lastClick > 500) + clickCount = 0 + else + clickCount++ + lastClick = now + if (clickCount == 7 && !Prefs.debugSettings) { + Prefs.debugSettings = true + L.d("Debugging section enabled") + toast(R.string.debug_toast_enabled) + } + } + false + } } class AboutLinks : AbstractItem(), ThemableIItem by ThemableIItemDelegate() { @@ -128,7 +142,7 @@ class AboutActivity : AboutActivityBase(null, { setImageDrawable(icon.toDrawable(context, 32)) scaleType = ImageView.ScaleType.CENTER background = context.resolveDrawable(android.R.attr.selectableItemBackgroundBorderless) - setOnClickListener({ onClick() }) + setOnClickListener { onClick() } container.addView(this) } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseActivity.kt index 77a20d04..c7ca5ec7 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseActivity.kt @@ -2,10 +2,16 @@ package com.pitchedapps.frost.activities import android.os.Bundle import ca.allanwang.kau.internal.KauBaseActivity +import com.github.pwittchen.reactivenetwork.library.rx2.Connectivity +import com.github.pwittchen.reactivenetwork.library.rx2.ReactiveNetwork import com.pitchedapps.frost.R +import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.materialDialogThemed import com.pitchedapps.frost.utils.setFrostTheme +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers /** * Created by Allan Wang on 2017-06-12. @@ -29,4 +35,41 @@ abstract class BaseActivity : KauBaseActivity() { setFrostTheme() } + private var networkDisposable: Disposable? = null + private var networkConsumer: ((Connectivity) -> Unit)? = null + + fun setNetworkObserver(consumer: (connectivity: Connectivity) -> Unit) { + this.networkConsumer = consumer + } + + fun observeNetworkConnectivity() { + val consumer = networkConsumer ?: return + networkDisposable = ReactiveNetwork.observeNetworkConnectivity(applicationContext) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + connectivity: Connectivity -> + connectivity.apply { + L.d("Network connectivity changed: isAvailable: $isAvailable isRoaming: $isRoaming") + consumer(connectivity) + } + } + } + + fun disposeNetworkConnectivity() { + if (!(networkDisposable?.isDisposed ?: true)) + networkDisposable?.dispose() + networkDisposable = null + } + + override fun onResume() { + super.onResume() + disposeNetworkConnectivity() + observeNetworkConnectivity() + } + + override fun onPause() { + super.onPause() + disposeNetworkConnectivity() + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/ImageActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/ImageActivity.kt index 6a39b269..31479d54 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/ImageActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/ImageActivity.kt @@ -7,7 +7,6 @@ import android.graphics.Color import android.graphics.drawable.Drawable import android.net.Uri import android.os.Bundle -import android.os.Environment import android.support.design.widget.FloatingActionButton import android.support.v4.content.FileProvider import android.view.View @@ -34,11 +33,8 @@ import com.pitchedapps.frost.utils.* import com.sothree.slidinguppanel.SlidingUpPanelLayout import org.jetbrains.anko.doAsync import org.jetbrains.anko.uiThread -import timber.log.Timber import java.io.File import java.io.IOException -import java.text.SimpleDateFormat -import java.util.* /** * Created by Allan Wang on 2017-07-15. @@ -99,8 +95,8 @@ class ImageActivity : KauBaseActivity() { }) fab.setOnClickListener { fabAction.onClick(this) } photo.setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() { - override fun onImageLoadError(e: Exception) { - L.e(e, "Image load error") + override fun onImageLoadError(e: Exception?) { + e.logFrostAnswers("Image load error") imageCallback(null, false) } }) @@ -155,7 +151,7 @@ class ImageActivity : KauBaseActivity() { callback(null) } else { tempFilePath = photoFile.absolutePath - Timber.d("Temp image path $tempFilePath") + L.d("Temp image path $tempFilePath") // File created; proceed with request val photoURI = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID + ".provider", @@ -252,7 +248,7 @@ internal enum class FabStates(val iicon: IIcon, val iconColor: Int = Prefs.iconC } activity.startActivity(intent) } catch (e: Exception) { - L.e(e, "Image share failed"); + e.logFrostAnswers("Image share failed") activity.snackbar(R.string.image_share_failed) } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt index 47c286fa..67f07635 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt @@ -12,6 +12,7 @@ import ca.allanwang.kau.utils.bindView import ca.allanwang.kau.utils.fadeIn import ca.allanwang.kau.utils.fadeOut import com.bumptech.glide.Glide +import com.bumptech.glide.RequestManager import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener @@ -44,6 +45,7 @@ class LoginActivity : BaseActivity() { val profileObservable = SingleSubject.create() val usernameObservable = SingleSubject.create() + lateinit var profileLoader: RequestManager // Helper to set and enable swipeRefresh var refresh: Boolean @@ -68,6 +70,7 @@ class LoginActivity : BaseActivity() { loadInfo(cookie) }) } + profileLoader = Glide.with(profile) } fun loadInfo(cookie: CookieModel) { @@ -78,8 +81,8 @@ class LoginActivity : BaseActivity() { (foundImage, name) -> refresh = false if (!foundImage) { - L.eThrow("Could not get profile photo; Invalid userId?") - L.i("-\t$cookie") + L.e("Could not get profile photo; Invalid userId?") + L.i(null, cookie.toString()) } textview.text = String.format(getString(R.string.welcome), name) textview.fadeIn() @@ -102,14 +105,14 @@ class LoginActivity : BaseActivity() { fun loadProfile(id: Long) { - Glide.with(profile).load(PROFILE_PICTURE_URL(id)).withRoundIcon().listener(object : RequestListener { + profileLoader.load(PROFILE_PICTURE_URL(id)).withRoundIcon().listener(object : RequestListener { override fun onResourceReady(resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { profileObservable.onSuccess(true) return false } override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean { - if (e != null) L.e(e, "Profile loading exception") + e.logFrostAnswers( "Profile loading exception") profileObservable.onSuccess(false) return false } @@ -119,12 +122,4 @@ class LoginActivity : BaseActivity() { fun loadUsername(cookie: CookieModel) { cookie.fetchUsername { usernameObservable.onSuccess(it) } } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == 999) { - L.d("Result found for activity with result $resultCode") - L.d("Intent data ${data?.extras.toString()}") - } else - super.onActivityResult(requestCode, resultCode, data) - } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt index e8148b55..759be983 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt @@ -50,7 +50,7 @@ import com.pitchedapps.frost.dbflow.loadFbCookie import com.pitchedapps.frost.dbflow.loadFbTabs import com.pitchedapps.frost.facebook.FbCookie import com.pitchedapps.frost.facebook.FbCookie.switchUser -import com.pitchedapps.frost.facebook.FbTab +import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.PROFILE_PICTURE_URL import com.pitchedapps.frost.fragments.WebFragment import com.pitchedapps.frost.utils.* @@ -60,6 +60,7 @@ import com.pitchedapps.frost.utils.iab.IS_FROST_PRO import com.pitchedapps.frost.views.BadgedIcon import com.pitchedapps.frost.views.FrostViewPager import com.pitchedapps.frost.web.SearchWebView +import com.pitchedapps.frost.web.shouldLoadImages import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers @@ -160,14 +161,10 @@ class MainActivity : BaseActivity(), SearchWebView.SearchContract, // } setFrostColors(toolbar, themeWindow = false, headers = arrayOf(tabs, appBar), backgrounds = arrayOf(viewPager)) onCreateBilling() - if (Prefs.installDate < 1501454310304 && Showcase.intro) - materialDialogThemed { - title(R.string.intro_title) - content(R.string.intro_desc) - positiveText(R.string.kau_yes) - negativeText(R.string.kau_no) - onPositive { _, _ -> launchIntroActivity(cookies()) } - } + setNetworkObserver { + connectivity -> + shouldLoadImages = !connectivity.isRoaming + } } fun tabsForEachView(action: (position: Int, view: BadgedIcon) -> Unit) { @@ -206,10 +203,10 @@ class MainActivity : BaseActivity(), SearchWebView.SearchContract, tabsForEachView { _, view -> when (view.iicon) { - FbTab.FEED.icon -> view.badgeText = feed - FbTab.FRIENDS.icon -> view.badgeText = requests - FbTab.MESSAGES.icon -> view.badgeText = messages - FbTab.NOTIFICATIONS.icon -> view.badgeText = notifications + FbItem.FEED.icon -> view.badgeText = feed + FbItem.FRIENDS.icon -> view.badgeText = requests + FbItem.MESSAGES.icon -> view.badgeText = messages + FbItem.NOTIFICATIONS.icon -> view.badgeText = notifications } } } @@ -230,10 +227,10 @@ class MainActivity : BaseActivity(), SearchWebView.SearchContract, translucentStatusBar = false sliderBackgroundColor = navBg drawerHeader = accountHeader { + customViewRes = R.layout.material_drawer_header textColor = Prefs.iconColor.toLong() backgroundDrawable = ColorDrawable(navHeader) selectionSecondLineShown = false - paddingBelow = false cookies().forEach { (id, name) -> profile(name = name ?: "") { iconUrl = PROFILE_PICTURE_URL(id) @@ -261,7 +258,7 @@ class MainActivity : BaseActivity(), SearchWebView.SearchContract, identifier = -4L } onProfileChanged { _, profile, current -> - if (current) launchWebOverlay(FbTab.PROFILE.url) + if (current) launchWebOverlay(FbItem.PROFILE.url) else when (profile.identifier) { -2L -> { val currentCookie = loadFbCookie(Prefs.userId) @@ -295,25 +292,25 @@ class MainActivity : BaseActivity(), SearchWebView.SearchContract, } } drawerHeader.setActiveProfile(Prefs.userId) - primaryFrostItem(FbTab.FEED_MOST_RECENT) - primaryFrostItem(FbTab.FEED_TOP_STORIES) - primaryFrostItem(FbTab.ACTIVITY_LOG) + primaryFrostItem(FbItem.FEED_MOST_RECENT) + primaryFrostItem(FbItem.FEED_TOP_STORIES) + primaryFrostItem(FbItem.ACTIVITY_LOG) divider() - primaryFrostItem(FbTab.PHOTOS) - primaryFrostItem(FbTab.GROUPS) - primaryFrostItem(FbTab.FRIENDS) - primaryFrostItem(FbTab.PAGES) + primaryFrostItem(FbItem.PHOTOS) + primaryFrostItem(FbItem.GROUPS) + primaryFrostItem(FbItem.FRIENDS) + primaryFrostItem(FbItem.PAGES) divider() - primaryFrostItem(FbTab.EVENTS) - primaryFrostItem(FbTab.BIRTHDAYS) - primaryFrostItem(FbTab.ON_THIS_DAY) + primaryFrostItem(FbItem.EVENTS) + primaryFrostItem(FbItem.BIRTHDAYS) + primaryFrostItem(FbItem.ON_THIS_DAY) divider() - primaryFrostItem(FbTab.NOTES) - primaryFrostItem(FbTab.SAVED) + primaryFrostItem(FbItem.NOTES) + primaryFrostItem(FbItem.SAVED) } } - fun Builder.primaryFrostItem(item: FbTab) = this.primaryItem(item.titleId) { + fun Builder.primaryFrostItem(item: FbItem) = this.primaryItem(item.titleId) { iicon = item.icon iconColor = Prefs.textColor.toLong() textColor = Prefs.textColor.toLong() @@ -349,6 +346,7 @@ class MainActivity : BaseActivity(), SearchWebView.SearchContract, override fun searchOverlayDispose() { hiddenSearchView?.dispose() hiddenSearchView = null + searchView?.unBind { launchWebOverlay(FbItem.SEARCH.url); true } searchView = null } @@ -369,10 +367,7 @@ class MainActivity : BaseActivity(), SearchWebView.SearchContract, if (Prefs.searchBar) { if (firstLoadFinished && hiddenSearchView == null) hiddenSearchView = SearchWebView(this, this) if (searchView == null) searchView = bindSearchView(menu, R.id.action_search, Prefs.iconColor) { - textObserver = { - observable, _ -> - observable.observeOn(AndroidSchedulers.mainThread()).subscribe { hiddenSearchView?.query(it) } - } + textCallback = { query, _ -> runOnUiThread { hiddenSearchView?.query(query) } } foregroundColor = Prefs.textColor backgroundColor = Prefs.bgColor.withMinAlpha(200) openListener = { hiddenSearchView?.pauseLoad = false } @@ -380,8 +375,8 @@ class MainActivity : BaseActivity(), SearchWebView.SearchContract, onItemClick = { _, key, _, _ -> launchWebOverlay(key) } } } else { - searchOverlayDispose() - menu.findItem(R.id.action_search).setOnMenuItemClickListener { _ -> launchWebOverlay(FbTab.SEARCH.url); true } + if (searchView != null) searchOverlayDispose() + else menu.findItem(R.id.action_search).setOnMenuItemClickListener { _ -> launchWebOverlay(FbItem.SEARCH.url); true } } return true } @@ -461,7 +456,7 @@ class MainActivity : BaseActivity(), SearchWebView.SearchContract, val currentFragment get() = supportFragmentManager.findFragmentByTag("android:switcher:${R.id.container}:${viewPager.currentItem}") as WebFragment - inner class SectionsPagerAdapter(fm: FragmentManager, val pages: List) : FragmentPagerAdapter(fm) { + inner class SectionsPagerAdapter(fm: FragmentManager, val pages: List) : FragmentPagerAdapter(fm) { override fun getItem(position: Int): Fragment { val fragment = WebFragment(pages[position], position) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/SettingsActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/SettingsActivity.kt index 7cbbe4df..196aa461 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/SettingsActivity.kt @@ -6,16 +6,12 @@ import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuItem -import ca.allanwang.kau.about.kauLaunchAbout import ca.allanwang.kau.kpref.activity.CoreAttributeContract import ca.allanwang.kau.kpref.activity.KPrefActivity import ca.allanwang.kau.kpref.activity.KPrefAdapterBuilder import ca.allanwang.kau.kpref.activity.items.KPrefItemBase import ca.allanwang.kau.ui.views.RippleCanvas -import ca.allanwang.kau.utils.finishSlideOut -import ca.allanwang.kau.utils.setMenuIcons -import ca.allanwang.kau.utils.string -import ca.allanwang.kau.utils.tint +import ca.allanwang.kau.utils.* import ca.allanwang.kau.xml.showChangelog import com.mikepenz.community_material_typeface_library.CommunityMaterial import com.mikepenz.google_material_typeface_library.GoogleMaterial @@ -38,7 +34,7 @@ class SettingsActivity : KPrefActivity(), FrostBilling by IABSettings() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (!onActivityResultBilling(requestCode, resultCode, data)) super.onActivityResult(requestCode, resultCode, data) - adapter.notifyDataSetChanged() + reloadList() } override fun kPrefCoreAttributes(): CoreAttributeContract.() -> Unit = { @@ -67,6 +63,11 @@ class SettingsActivity : KPrefActivity(), FrostBilling by IABSettings() { iicon = GoogleMaterial.Icon.gmd_notifications } + subItems(R.string.network, getNetworkPrefs()) { + descRes = R.string.network_desc + iicon = GoogleMaterial.Icon.gmd_network_cell + } + subItems(R.string.experimental, getExperimentalPrefs()) { descRes = R.string.experimental_desc iicon = CommunityMaterial.Icon.cmd_flask_outline @@ -81,7 +82,7 @@ class SettingsActivity : KPrefActivity(), FrostBilling by IABSettings() { plainText(R.string.about_frost) { descRes = R.string.about_frost_desc iicon = GoogleMaterial.Icon.gmd_info - onClick = { _, _, _ -> kauLaunchAbout(AboutActivity::class.java); true } + onClick = { _, _, _ -> startActivityForResult(AboutActivity::class.java, 9, true); true } } plainText(R.string.replay_intro) { @@ -89,6 +90,12 @@ class SettingsActivity : KPrefActivity(), FrostBilling by IABSettings() { onClick = { _, _, _ -> launchIntroActivity(cookies()); true } } + subItems(R.string.debug_frost, getDebugPrefs()) { + descRes = R.string.debug_frost_desc + iicon = CommunityMaterial.Icon.cmd_android_debug_bridge + visible = { Prefs.debugSettings } + } + if (BuildConfig.DEBUG) { checkbox(R.string.custom_pro, { Prefs.debugPro }, { Prefs.debugPro = it }) } @@ -130,8 +137,6 @@ class SettingsActivity : KPrefActivity(), FrostBilling by IABSettings() { override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_settings, menu) toolbar.tint(Prefs.iconColor) - toolbarTitle.textColor = Prefs.iconColor - toolbarTitle.invalidate() setMenuIcons(menu, Prefs.iconColor, R.id.action_email to GoogleMaterial.Icon.gmd_email, R.id.action_changelog to GoogleMaterial.Icon.gmd_info) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/contracts/FileChooser.kt b/app/src/main/kotlin/com/pitchedapps/frost/contracts/FileChooser.kt index fd8a3677..f3d90bcc 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/contracts/FileChooser.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/contracts/FileChooser.kt @@ -5,8 +5,6 @@ import android.content.Intent import android.net.Uri import android.webkit.ValueCallback import android.webkit.WebChromeClient -import ca.allanwang.kau.mediapicker.MediaPickerActivityOverlayBase -import ca.allanwang.kau.mediapicker.MediaType import ca.allanwang.kau.mediapicker.kauLaunchMediaPicker import ca.allanwang.kau.mediapicker.kauOnMediaPickerResult import com.pitchedapps.frost.activities.ImagePickerActivity diff --git a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt index 901ba02d..92cdf503 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt @@ -2,16 +2,18 @@ package com.pitchedapps.frost.dbflow import android.os.Parcel import android.os.Parcelable +import com.github.pwittchen.reactivenetwork.library.rx2.ReactiveNetwork import com.pitchedapps.frost.facebook.FACEBOOK_COM -import com.pitchedapps.frost.facebook.FbTab +import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.utils.L +import com.pitchedapps.frost.utils.logFrostAnswers import com.raizlabs.android.dbflow.annotation.ConflictAction import com.raizlabs.android.dbflow.annotation.Database import com.raizlabs.android.dbflow.annotation.PrimaryKey import com.raizlabs.android.dbflow.annotation.Table import com.raizlabs.android.dbflow.kotlinextensions.* import com.raizlabs.android.dbflow.structure.BaseModel -import org.jetbrains.anko.doAsync +import io.reactivex.schedulers.Schedulers import org.jsoup.Jsoup import paperparcel.PaperParcel import java.net.UnknownHostException @@ -64,20 +66,22 @@ fun removeCookie(id: Long) { } fun CookieModel.fetchUsername(callback: (String) -> Unit) { - doAsync { + ReactiveNetwork.checkInternetConnectivity().subscribeOn(Schedulers.io()).subscribe { + yes, _ -> + if (!yes) return@subscribe callback("") var result = "" try { - result = Jsoup.connect(FbTab.PROFILE.url) + result = Jsoup.connect(FbItem.PROFILE.url) .cookie(FACEBOOK_COM, cookie) .get().title() L.d("Fetch username found", result) } catch (e: Exception) { if (e !is UnknownHostException) - L.e(e, "Fetch username failed") + e.logFrostAnswers("Fetch username failed") } finally { if (result.isBlank() && (name?.isNotBlank() ?: false)) { callback(name!!) - return@doAsync + return@subscribe } if (name != result) { name = result diff --git a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/FbTabsDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/dbflow/FbTabsDb.kt index 69c7f3d5..4b2d3403 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/FbTabsDb.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/dbflow/FbTabsDb.kt @@ -1,7 +1,7 @@ package com.pitchedapps.frost.dbflow import android.content.Context -import com.pitchedapps.frost.facebook.FbTab +import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.defaultTabs import com.pitchedapps.frost.utils.L import com.raizlabs.android.dbflow.annotation.Database @@ -22,15 +22,15 @@ object FbTabsDb { } @Table(database = FbTabsDb::class, allFields = true) -data class FbTabModel(@PrimaryKey var position: Int = -1, var tab: FbTab = FbTab.FEED) : BaseModel() +data class FbTabModel(@PrimaryKey var position: Int = -1, var tab: FbItem = FbItem.FEED) : BaseModel() -fun loadFbTabs(): List { +fun loadFbTabs(): List { val tabs: List? = SQLite.select().from(FbTabModel::class).orderBy(FbTabModel_Table.position, true).queryList() if (tabs?.isNotEmpty() ?: false) return tabs!!.map { it.tab } L.d("No tabs; loading default") return defaultTabs() } -fun List.saveAsync(c: Context) { +fun List.saveAsync(c: Context) { mapIndexed { index, fbTab -> FbTabModel(index, fbTab) }.replace(c, FbTabsDb.NAME) } \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt new file mode 100644 index 00000000..a4736091 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt @@ -0,0 +1,39 @@ +package com.pitchedapps.frost.facebook + +import android.support.annotation.StringRes +import com.mikepenz.community_material_typeface_library.CommunityMaterial +import com.mikepenz.google_material_typeface_library.GoogleMaterial +import com.mikepenz.iconics.typeface.IIcon +import com.mikepenz.material_design_iconic_typeface_library.MaterialDesignIconic +import com.pitchedapps.frost.R +import com.pitchedapps.frost.web.FrostWebViewClient +import com.pitchedapps.frost.web.FrostWebViewClientMenu +import com.pitchedapps.frost.web.FrostWebViewCore + +enum class FbItem(@StringRes val titleId: Int, val icon: IIcon, relativeUrl: String, val webClient: ((webCore: FrostWebViewCore) -> FrostWebViewClient)? = null) { + ACTIVITY_LOG(R.string.activity_log, GoogleMaterial.Icon.gmd_list, "me/allactivity"), + BIRTHDAYS(R.string.birthdays, GoogleMaterial.Icon.gmd_cake, "events/birthdays"), + CHAT(R.string.chat, GoogleMaterial.Icon.gmd_chat, "buddylist"), + EVENTS(R.string.events, GoogleMaterial.Icon.gmd_event_note, "events/upcoming"), + FEED(R.string.feed, CommunityMaterial.Icon.cmd_newspaper, ""), + FEED_MOST_RECENT(R.string.most_recent, GoogleMaterial.Icon.gmd_history, "home.php?sk=h_chr"), + FEED_TOP_STORIES(R.string.top_stories, GoogleMaterial.Icon.gmd_star, "home.php?sk=h_nor"), + FRIENDS(R.string.friends, GoogleMaterial.Icon.gmd_person_add, "friends/center/requests"), + GROUPS(R.string.groups, GoogleMaterial.Icon.gmd_group, "groups"), + MENU(R.string.menu, GoogleMaterial.Icon.gmd_menu, "settings", { FrostWebViewClientMenu(it) }), + MESSAGES(R.string.messages, MaterialDesignIconic.Icon.gmi_comments, "messages"), + NOTES(R.string.notes, CommunityMaterial.Icon.cmd_note, "notes"), + NOTIFICATIONS(R.string.notifications, MaterialDesignIconic.Icon.gmi_globe, "notifications"), + ON_THIS_DAY(R.string.on_this_day, GoogleMaterial.Icon.gmd_today, "onthisday"), + PAGES(R.string.pages, GoogleMaterial.Icon.gmd_flag, "pages"), + PHOTOS(R.string.photos, GoogleMaterial.Icon.gmd_photo, "me/photos"), + PROFILE(R.string.profile, CommunityMaterial.Icon.cmd_account, "me"), + SAVED(R.string.saved, GoogleMaterial.Icon.gmd_bookmark, "saved"), + SEARCH(R.string.search_menu_title, GoogleMaterial.Icon.gmd_search, "search"), + SETTINGS(R.string.settings, GoogleMaterial.Icon.gmd_settings, "settings"), + ; + + val url = "$FB_URL_BASE$relativeUrl" +} + +fun defaultTabs(): List = listOf(FbItem.FEED, FbItem.MESSAGES, FbItem.NOTIFICATIONS, FbItem.MENU) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbTab.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbTab.kt deleted file mode 100644 index d1f0b046..00000000 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbTab.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.pitchedapps.frost.facebook - -import android.support.annotation.StringRes -import com.mikepenz.community_material_typeface_library.CommunityMaterial -import com.mikepenz.google_material_typeface_library.GoogleMaterial -import com.mikepenz.iconics.typeface.IIcon -import com.mikepenz.material_design_iconic_typeface_library.MaterialDesignIconic -import com.pitchedapps.frost.R -import com.pitchedapps.frost.web.FrostWebViewClient -import com.pitchedapps.frost.web.FrostWebViewClientMenu -import com.pitchedapps.frost.web.FrostWebViewCore - -enum class FbTab(@StringRes val titleId: Int, val icon: IIcon, relativeUrl: String, val webClient: ((webCore: FrostWebViewCore) -> FrostWebViewClient)? = null) { - ACTIVITY_LOG(R.string.activity_log, GoogleMaterial.Icon.gmd_list, "me/allactivity"), - BIRTHDAYS(R.string.birthdays, GoogleMaterial.Icon.gmd_cake, "events/birthdays"), - CHAT(R.string.chat, GoogleMaterial.Icon.gmd_chat, "buddylist"), - EVENTS(R.string.events, GoogleMaterial.Icon.gmd_event_note, "events/upcoming"), - FEED(R.string.feed, CommunityMaterial.Icon.cmd_newspaper, ""), - FEED_MOST_RECENT(R.string.most_recent, GoogleMaterial.Icon.gmd_history, "home.php?sk=h_chr"), - FEED_TOP_STORIES(R.string.top_stories, GoogleMaterial.Icon.gmd_star, "home.php?sk=h_nor"), - FRIENDS(R.string.friends, GoogleMaterial.Icon.gmd_person_add, "friends/center/requests"), - GROUPS(R.string.groups, GoogleMaterial.Icon.gmd_group, "groups"), - MENU(R.string.menu, GoogleMaterial.Icon.gmd_menu, "settings", { FrostWebViewClientMenu(it) }), - MESSAGES(R.string.messages, MaterialDesignIconic.Icon.gmi_comments, "messages"), - NOTES(R.string.notes, CommunityMaterial.Icon.cmd_note, "notes"), - NOTIFICATIONS(R.string.notifications, MaterialDesignIconic.Icon.gmi_globe, "notifications"), - ON_THIS_DAY(R.string.on_this_day, GoogleMaterial.Icon.gmd_today, "onthisday"), - PAGES(R.string.pages, GoogleMaterial.Icon.gmd_flag, "pages"), - PHOTOS(R.string.photos, GoogleMaterial.Icon.gmd_photo, "me/photos"), - PROFILE(R.string.profile, CommunityMaterial.Icon.cmd_account, "me"), - SAVED(R.string.saved, GoogleMaterial.Icon.gmd_bookmark, "saved"), - SEARCH(R.string.search_menu_title, GoogleMaterial.Icon.gmd_search, "search"), - SETTINGS(R.string.settings, GoogleMaterial.Icon.gmd_settings, "settings"), - ; - - val url = "$FB_URL_BASE$relativeUrl" -} - -fun defaultTabs(): List = listOf(FbTab.FEED, FbTab.MESSAGES, FbTab.NOTIFICATIONS, FbTab.MENU) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbUrlFormatter.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbUrlFormatter.kt index 7cd93d14..69b2ba41 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbUrlFormatter.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbUrlFormatter.kt @@ -33,7 +33,7 @@ class FbUrlFormatter(url: String) { if (cleanedUrl.startsWith("#!/")) cleanedUrl = cleanedUrl.substring(2) if (cleanedUrl.startsWith("/")) cleanedUrl = FB_URL_BASE + cleanedUrl.substring(1) cleanedUrl = cleanedUrl.replaceFirst(".facebook.com//", ".facebook.com/") //sometimes we are given a bad url - L.v("Formatted url from $url to $cleanedUrl") + L.v(null, "Formatted url from $url to $cleanedUrl") cleaned = cleanedUrl } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/UsernameFetcher.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/UsernameFetcher.kt index dfdfa027..f2bcc328 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/UsernameFetcher.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/UsernameFetcher.kt @@ -3,6 +3,7 @@ package com.pitchedapps.frost.facebook import com.pitchedapps.frost.dbflow.CookieModel import com.pitchedapps.frost.dbflow.saveFbCookie import com.pitchedapps.frost.utils.L +import com.pitchedapps.frost.utils.logFrostAnswers import io.reactivex.subjects.SingleSubject import org.jsoup.Jsoup import kotlin.concurrent.thread @@ -16,12 +17,12 @@ object UsernameFetcher { thread { var name = "" try { - name = Jsoup.connect(FbTab.PROFILE.url) + name = Jsoup.connect(FbItem.PROFILE.url) .cookie(FACEBOOK_COM, data.cookie) .get().title() L.d("User name found", name) } catch (e: Exception) { - L.e(e, "User name fetching failed") + e.logFrostAnswers("User name fetching failed") } finally { data.name = name saveFbCookie(data) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/WebFragment.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/WebFragment.kt index 239f5842..920052f9 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/WebFragment.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/WebFragment.kt @@ -8,7 +8,7 @@ import android.view.View import android.view.ViewGroup import ca.allanwang.kau.utils.withArguments import com.pitchedapps.frost.activities.MainActivity -import com.pitchedapps.frost.facebook.FbTab +import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.FeedSort import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.web.FrostWebView @@ -30,15 +30,15 @@ class WebFragment : Fragment() { const val REQUEST_TEXT_ZOOM = 17 const val REQUEST_REFRESH = 99 - operator fun invoke(data: FbTab, position: Int) = WebFragment().withArguments( + operator fun invoke(data: FbItem, position: Int) = WebFragment().withArguments( ARG_URL to data.url, ARG_POSITION to position, ARG_URL_ENUM to when (data) { //If is feed, check if sorting method is specified - FbTab.FEED -> when (FeedSort(Prefs.feedSort)) { + FbItem.FEED -> when (FeedSort(Prefs.feedSort)) { FeedSort.DEFAULT -> data - FeedSort.MOST_RECENT -> FbTab.FEED_MOST_RECENT - FeedSort.TOP -> FbTab.FEED_TOP_STORIES + FeedSort.MOST_RECENT -> FbItem.FEED_MOST_RECENT + FeedSort.TOP -> FbItem.FEED_TOP_STORIES } else -> data }) @@ -47,7 +47,7 @@ class WebFragment : Fragment() { // val refresh: SwipeRefreshLayout by lazy { frostWebView.refresh } val web: FrostWebViewCore by lazy { frostWebView.web } val url: String by lazy { arguments.getString(ARG_URL) } - val urlEnum: FbTab by lazy { arguments.getSerializable(ARG_URL_ENUM) as FbTab } + val urlEnum: FbItem by lazy { arguments.getSerializable(ARG_URL_ENUM) as FbItem } val position: Int by lazy { arguments.getInt(ARG_POSITION) } lateinit var frostWebView: FrostWebView private var firstLoad = true diff --git a/app/src/main/kotlin/com/pitchedapps/frost/injectors/CssAssets.kt b/app/src/main/kotlin/com/pitchedapps/frost/injectors/CssAssets.kt index 0992a9cb..733bc151 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/injectors/CssAssets.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/injectors/CssAssets.kt @@ -3,7 +3,10 @@ package com.pitchedapps.frost.injectors import android.graphics.Color import android.webkit.WebView import ca.allanwang.kau.utils.* +import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs +import java.io.FileNotFoundException +import java.util.* /** * Created by Allan Wang on 2017-05-31. @@ -14,33 +17,38 @@ enum class CssAssets(val folder: String = "themes") : InjectorContract { MATERIAL_LIGHT, MATERIAL_DARK, MATERIAL_AMOLED, MATERIAL_GLASS, CUSTOM, ROUND_ICONS("components") ; - var file = "${name.toLowerCase()}.compact.css" + var file = "${name.toLowerCase(Locale.CANADA)}.compact.css" var injector: JsInjector? = null override fun inject(webView: WebView, callback: ((String) -> Unit)?) { if (injector == null) { - var content = webView.context.assets.open("css/$folder/$file").bufferedReader().use { it.readText() } - if (this == CUSTOM) { - var bbt = Prefs.bgColor - val bt: String - if (Color.alpha(bbt) == 255) { - bbt = bbt.adjustAlpha(0.2f).colorToForeground(0.35f) - bt = Prefs.bgColor.toRgbaString() - } else { - bbt = bbt.adjustAlpha(0.05f).colorToForeground(0.5f) - bt = "transparent" + try { + var content = webView.context.assets.open("css/$folder/$file").bufferedReader().use { it.readText() } + if (this == CUSTOM) { + var bbt = Prefs.bgColor + val bt: String + if (Color.alpha(bbt) == 255) { + bbt = bbt.adjustAlpha(0.2f).colorToForeground(0.35f) + bt = Prefs.bgColor.toRgbaString() + } else { + bbt = bbt.adjustAlpha(0.05f).colorToForeground(0.5f) + bt = "transparent" + } + content = content + .replace("\$T\$", Prefs.textColor.toRgbaString()) + .replace("\$TT\$", Prefs.textColor.colorToBackground(0.05f).toRgbaString()) + .replace("\$B\$", Prefs.bgColor.toRgbaString()) + .replace("\$BT\$", bt) + .replace("\$BBT\$", bbt.toRgbaString()) + .replace("\$O\$", Prefs.bgColor.withAlpha(255).toRgbaString()) + .replace("\$OO\$", Prefs.bgColor.colorToForeground(0.35f).withAlpha(255).toRgbaString()) + .replace("\$D\$", Prefs.textColor.adjustAlpha(0.3f).toRgbaString()) } - content = content - .replace("\$T\$", Prefs.textColor.toRgbaString()) - .replace("\$TT\$", Prefs.textColor.colorToBackground(0.05f).toRgbaString()) - .replace("\$B\$", Prefs.bgColor.toRgbaString()) - .replace("\$BT\$", bt) - .replace("\$BBT\$", bbt.toRgbaString()) - .replace("\$O\$", Prefs.bgColor.withAlpha(255).toRgbaString()) - .replace("\$OO\$", Prefs.bgColor.colorToForeground(0.35f).withAlpha(255).toRgbaString()) - .replace("\$D\$", Prefs.textColor.adjustAlpha(0.3f).toRgbaString()) + injector = JsBuilder().css(content).build() + } catch (e: FileNotFoundException) { + L.e(e, "CssAssets file not found") + injector = JsInjector(JsActions.EMPTY.function) } - injector = JsBuilder().css(content).build() } injector!!.inject(webView, callback) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsActions.kt b/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsActions.kt index fae1846b..3fa03bcc 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsActions.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsActions.kt @@ -14,7 +14,8 @@ enum class JsActions(body: String) : InjectorContract { * see [com.pitchedapps.frost.web.FrostJSI.loadLogin] */ LOGIN_CHECK("document.getElementById('signup-button')&&Frost.loadLogin();"), - BASE_HREF("document.write(\"\");"), + BASE_HREF("""document.write("");"""), + FETCH_BODY("""setTimeout(function(){var e=document.querySelector("main");e||(e=document.querySelector("body")),Frost.handleHtml(e.outerHTML)},1e2);"""), /** * Used as a pseudoinjector for maybe functions */ diff --git a/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsAssets.kt b/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsAssets.kt index d2201c52..27b0f92a 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsAssets.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsAssets.kt @@ -1,6 +1,9 @@ package com.pitchedapps.frost.injectors import android.webkit.WebView +import com.pitchedapps.frost.utils.L +import java.io.FileNotFoundException +import java.util.* /** * Created by Allan Wang on 2017-05-31. @@ -11,13 +14,18 @@ enum class JsAssets : InjectorContract { MENU, CLICK_A, CONTEXT_A, HEADER_BADGES, SEARCH, TEXTAREA_LISTENER, NOTIF_MSG ; - var file = "${name.toLowerCase()}.min.js" + var file = "${name.toLowerCase(Locale.CANADA)}.min.js" var injector: JsInjector? = null override fun inject(webView: WebView, callback: ((String) -> Unit)?) { if (injector == null) { - val content = webView.context.assets.open("js/$file").bufferedReader().use { it.readText() } - injector = JsBuilder().js(content).build() + try { + val content = webView.context.assets.open("js/$file").bufferedReader().use { it.readText() } + injector = JsBuilder().js(content).build() + } catch (e: FileNotFoundException) { + L.e(e, "JsAssets file not found") + injector = JsInjector(JsActions.EMPTY.function) + } } injector!!.inject(webView, callback) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt index 2453d3b0..d3dfe79c 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt @@ -21,7 +21,6 @@ import com.pitchedapps.frost.BuildConfig import com.pitchedapps.frost.R import com.pitchedapps.frost.activities.FrostWebActivity import com.pitchedapps.frost.dbflow.CookieModel -import com.pitchedapps.frost.dbflow.fetchUsername import com.pitchedapps.frost.facebook.formattedFbUrl import com.pitchedapps.frost.utils.ARG_USER_ID import com.pitchedapps.frost.utils.L @@ -31,6 +30,12 @@ import org.jetbrains.anko.runOnUiThread /** * Created by Allan Wang on 2017-07-08. + * + * Logic for build notifications, scheduling notifications, and showing notifications + */ + +/** + * Wrap the default builder with our icon and accent color */ val Context.frostNotification: NotificationCompat.Builder get() = NotificationCompat.Builder(this, BuildConfig.APPLICATION_ID).apply { @@ -39,6 +44,9 @@ val Context.frostNotification: NotificationCompat.Builder color = color(R.color.frost_notification_accent) } +/** + * Assign global changes to the notification after it is built + */ @Suppress("DEPRECATION") //The update feature is for Android O and seems to still be in beta fun Notification.frostConfig() = apply { @@ -54,6 +62,7 @@ val NotificationCompat.Builder.withBigText: NotificationCompat.BigTextStyle * Created by Allan Wang on 2017-07-08. * * Custom target to set the content view and update a given notification + * 40dp is the size of the right avatar */ class FrostNotificationTarget(val context: Context, val notifId: Int, @@ -67,6 +76,9 @@ class FrostNotificationTarget(val context: Context, } } +internal const val FROST_NOTIFICATION_GROUP = "frost" +internal const val FROST_MESSAGE_NOTIFICATION_GROUP = "frost_im" + /** * Notification data holder */ @@ -77,39 +89,35 @@ data class NotificationContent(val data: CookieModel, val text: String, val timestamp: Long, val profileUrl: String) { - fun createNotification(context: Context, verifiedUser: Boolean = false) { - //in case we haven't found the name, we will try one more time before passing the notification - if (!verifiedUser && data.name?.isBlank() ?: true) { - data.fetchUsername { - data.name = it - createNotification(context, true) - } - } else { - val intent = Intent(context, FrostWebActivity::class.java) - intent.data = Uri.parse(href.formattedFbUrl) - intent.putExtra(ARG_USER_ID, data.id) - val group = "frost_${data.id}" - val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0) - val notifBuilder = context.frostNotification - .setContentTitle(title ?: context.string(R.string.frost_name)) - .setContentText(text) - .setContentIntent(pendingIntent) - .setCategory(Notification.CATEGORY_SOCIAL) - .setSubText(data.name) - .setGroup(group) - - if (timestamp != -1L) notifBuilder.setWhen(timestamp * 1000) - L.v("Notif load $this") - NotificationManagerCompat.from(context).notify(group, notifId, notifBuilder.withBigText.build().frostConfig()) - - if (profileUrl.isNotBlank()) { - context.runOnUiThread { - Glide.with(context) - .asBitmap() - .load(profileUrl) - .withRoundIcon() - .into(FrostNotificationTarget(context, notifId, group, notifBuilder)) - } + fun createNotification(context: Context) = createNotification(context, FROST_NOTIFICATION_GROUP) + + fun createMessageNotification(context: Context) = createNotification(context, FROST_MESSAGE_NOTIFICATION_GROUP) + + private fun createNotification(context: Context, groupPrefix: String) { + val intent = Intent(context, FrostWebActivity::class.java) + intent.data = Uri.parse(href.formattedFbUrl) + intent.putExtra(ARG_USER_ID, data.id) + val group = "${groupPrefix}_${data.id}" + val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0) + val notifBuilder = context.frostNotification + .setContentTitle(title ?: context.string(R.string.frost_name)) + .setContentText(text) + .setContentIntent(pendingIntent) + .setCategory(Notification.CATEGORY_SOCIAL) + .setSubText(data.name) + .setGroup(group) + + if (timestamp != -1L) notifBuilder.setWhen(timestamp * 1000) + L.v("Notif load", this.toString()) + NotificationManagerCompat.from(context).notify(group, notifId, notifBuilder.withBigText.build().frostConfig()) + + if (profileUrl.isNotBlank()) { + context.runOnUiThread { + Glide.with(context) + .asBitmap() + .load(profileUrl) + .withRoundIcon() + .into(FrostNotificationTarget(context, notifId, group, notifBuilder)) } } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt index fe7758cc..5859a306 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt @@ -1,26 +1,33 @@ package com.pitchedapps.frost.services +import android.app.Notification +import android.app.PendingIntent import android.app.job.JobParameters import android.app.job.JobService import android.content.Context +import android.content.Intent +import android.net.Uri import android.support.v4.app.NotificationManagerCompat import ca.allanwang.kau.utils.string import com.pitchedapps.frost.BuildConfig import com.pitchedapps.frost.R +import com.pitchedapps.frost.activities.FrostWebActivity import com.pitchedapps.frost.dbflow.CookieModel import com.pitchedapps.frost.dbflow.lastNotificationTime import com.pitchedapps.frost.dbflow.loadFbCookie import com.pitchedapps.frost.dbflow.loadFbCookiesSync import com.pitchedapps.frost.facebook.FACEBOOK_COM -import com.pitchedapps.frost.facebook.FbTab +import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.USER_AGENT_BASIC import com.pitchedapps.frost.facebook.formattedFbUrl +import com.pitchedapps.frost.injectors.JsAssets +import com.pitchedapps.frost.utils.ARG_USER_ID import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.frostAnswersCustom -import com.pitchedapps.frost.web.MessageWebView +import com.pitchedapps.frost.web.launchHeadlessHtmlExtractor +import io.reactivex.schedulers.Schedulers import org.jetbrains.anko.doAsync -import org.jetbrains.anko.uiThread import org.jsoup.Jsoup import org.jsoup.nodes.Element import java.util.concurrent.Future @@ -30,6 +37,9 @@ import java.util.concurrent.Future * * Service to manage notifications * Will periodically check through all accounts in the db and send notifications when appropriate + * + * Note that general notifications are parsed directly with Jsoup, + * but instant messages are done so with a headless webview as it is generated from JS */ class NotificationService : JobService() { @@ -58,7 +68,7 @@ class NotificationService : JobService() { fun finish(params: JobParameters?) { val time = System.currentTimeMillis() - startTime - L.d("Notification service has finished in $time ms") + L.i("Notification service has finished in $time ms") frostAnswersCustom("NotificationTime", "Type" to "Service", "IM Included" to Prefs.notificationsInstantMessages, @@ -68,8 +78,8 @@ class NotificationService : JobService() { future = null } - override fun onStartJob(params: JobParameters?): Boolean { + L.i("Fetching notifications") future = doAsync { if (Prefs.notificationAllAccounts) { val cookies = loadFbCookiesSync() @@ -83,9 +93,15 @@ class NotificationService : JobService() { L.d("Finished main notifications") if (Prefs.notificationsInstantMessages) { val currentCookie = loadFbCookie(Prefs.userId) - if (currentCookie != null) - uiThread { MessageWebView(this@NotificationService, params, currentCookie) } - } else finish(params) + if (currentCookie != null) { + fetchMessageNotifications(currentCookie) { + L.i("Notif IM fetching finished ${if (it) "succesfully" else "unsuccessfully"}") + finish(params) + } + return@doAsync + } + } + finish(params) } return true } @@ -95,13 +111,21 @@ class NotificationService : JobService() { return null } + /* + * ---------------------------------------------------------------- + * General notification logic. + * Fetch notifications -> Filter new ones -> Parse notifications -> + * Show notifications -> Show group notification + * ---------------------------------------------------------------- + */ + fun fetchGeneralNotifications(data: CookieModel) { - L.i("Notif fetch for $data") - val doc = Jsoup.connect(FbTab.NOTIFICATIONS.url).cookie(FACEBOOK_COM, data.cookie).userAgent(USER_AGENT_BASIC).get() + L.d("Notif fetch", data.toString()) + val doc = Jsoup.connect(FbItem.NOTIFICATIONS.url).cookie(FACEBOOK_COM, data.cookie).userAgent(USER_AGENT_BASIC).get() //aclb for unread, acw for read val unreadNotifications = (doc.getElementById("notifications_list") ?: return L.eThrow("Notification list not found")).getElementsByClass("aclb") var notifCount = 0 -// val prevLatestEpoch = 1498931565L // for testing + //val prevLatestEpoch = 1498931565L // for testing val prevNotifTime = lastNotificationTime(data.id) val prevLatestEpoch = prevNotifTime.epoch L.v("Notif Prev Latest Epoch $prevLatestEpoch") @@ -122,7 +146,6 @@ class NotificationService : JobService() { summaryNotification(data.id, notifCount) } - fun parseNotification(data: CookieModel, element: Element): NotificationContent? { val a = element.getElementsByTag("a").first() ?: return logNotif("IM No a tag") val abbr = element.getElementsByTag("abbr") @@ -134,13 +157,36 @@ class NotificationService : JobService() { if (Prefs.notificationKeywords.any { text.contains(it, ignoreCase = true) }) return null //notification filtered out //fetch profpic val p = element.select("i.img[style*=url]") - val pUrl = profMatcher.find(p.attr("style"))?.groups?.get(1)?.value ?: "" + val pUrl = profMatcher.find(p.attr("style"))?.groups?.get(1)?.value?.formattedFbUrl ?: "" return NotificationContent(data, notifId.toInt(), a.attr("href"), null, text, epoch, pUrl) } - fun fetchMessageNotifications(data: CookieModel, content: String) { - L.i("Notif IM fetch for $data") - val doc = Jsoup.parseBodyFragment(content) + fun summaryNotification(userId: Long, count: Int) + = summaryNotification(userId, count, R.string.notifications, FbItem.NOTIFICATIONS.url, FROST_NOTIFICATION_GROUP) + + /* + * ---------------------------------------------------------------- + * Instant message notification logic. + * Fetch notifications -> Filter new ones -> Parse notifications -> + * Show notifications -> Show group notification + * ---------------------------------------------------------------- + */ + + inline fun fetchMessageNotifications(data: CookieModel, crossinline callback: (success: Boolean) -> Unit) { + launchHeadlessHtmlExtractor(FbItem.MESSAGES.url, JsAssets.NOTIF_MSG) { + it.observeOn(Schedulers.newThread()).subscribe { + (html, errorRes) -> + L.d("Notf IM html received") + if (errorRes != -1) return@subscribe callback(false) + fetchMessageNotifications(data, html) + callback(true) + } + } + } + + fun fetchMessageNotifications(data: CookieModel, html: String) { + L.d("Notif IM fetch", data.toString()) + val doc = Jsoup.parseBodyFragment(html) val unreadNotifications = (doc.getElementById("threadlist_rows") ?: return L.eThrow("Notification messages not found")).getElementsByClass("aclb") var notifCount = 0 val prevNotifTime = lastNotificationTime(data.id) @@ -152,7 +198,7 @@ class NotificationService : JobService() { val notif = parseMessageNotification(data, elem) ?: return@unread L.v("Notif im timestamp ${notif.timestamp}") if (notif.timestamp <= prevLatestEpoch) return@unread - notif.createNotification(this@NotificationService) + notif.createMessageNotification(this@NotificationService) if (notif.timestamp > newLatestEpoch) newLatestEpoch = notif.timestamp notifCount++ @@ -160,7 +206,7 @@ class NotificationService : JobService() { if (newLatestEpoch != prevLatestEpoch) prevNotifTime.copy(epochIm = newLatestEpoch).save() L.d("Notif new latest im epoch ${lastNotificationTime(data.id).epochIm}") frostAnswersCustom("Notifications", "Type" to "Message", "Count" to notifCount) - summaryNotification(data.id, notifCount) + summaryMessageNotification(data.id, notifCount) } fun parseMessageNotification(data: CookieModel, element: Element): NotificationContent? { @@ -174,11 +220,14 @@ class NotificationService : JobService() { if (Prefs.notificationKeywords.any { text.contains(it, ignoreCase = true) }) return null //notification filtered out //fetch convo pic val p = element.select("i.img[style*=url]") - val pUrl = profMatcher.find(p.attr("style"))?.groups?.get(1)?.value ?: "" - L.v("url ${a.attr("href")}") - return NotificationContent(data, notifId.toInt(), a.attr("href"), a.text(), text, epoch, pUrl.formattedFbUrl) + val pUrl = profMatcher.find(p.attr("style"))?.groups?.get(1)?.value?.formattedFbUrl ?: "" + L.v("url", a.attr("href")) + return NotificationContent(data, notifId.toInt(), a.attr("href"), a.text(), text, epoch, pUrl) } + fun summaryMessageNotification(userId: Long, count: Int) + = summaryNotification(userId, count, R.string.messages, FbItem.MESSAGES.url, FROST_MESSAGE_NOTIFICATION_GROUP) + private fun Context.debugNotification(text: String) { if (!BuildConfig.DEBUG) return val notifBuilder = frostNotification @@ -187,15 +236,21 @@ class NotificationService : JobService() { NotificationManagerCompat.from(this).notify(999, notifBuilder.build().frostConfig()) } - fun summaryNotification(userId: Long, count: Int) { + private fun summaryNotification(userId: Long, count: Int, contentRes: Int, pendingUrl: String, groupPrefix: String) { if (count <= 1) return + val intent = Intent(this, FrostWebActivity::class.java) + intent.data = Uri.parse(pendingUrl) + intent.putExtra(ARG_USER_ID, userId) + val pendingIntent = PendingIntent.getActivity(this, 0, intent, 0) val notifBuilder = frostNotification .setContentTitle(string(R.string.frost_name)) - .setContentText("$count notifications") - .setGroup("frost_$userId") + .setContentText("$count ${string(contentRes)}") + .setGroup("${groupPrefix}_$userId") .setGroupSummary(true) + .setContentIntent(pendingIntent) + .setCategory(Notification.CATEGORY_SOCIAL) - NotificationManagerCompat.from(this).notify("frost_$userId", userId.toInt(), notifBuilder.build().frostConfig()) + NotificationManagerCompat.from(this).notify("${groupPrefix}_$userId", userId.toInt(), notifBuilder.build().frostConfig()) } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/UpdateReceiver.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/UpdateReceiver.kt index 52f7412f..9e53889e 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/UpdateReceiver.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/UpdateReceiver.kt @@ -13,6 +13,7 @@ import com.pitchedapps.frost.utils.Prefs */ class UpdateReceiver : BroadcastReceiver() { + //todo check action warning override fun onReceive(context: Context, intent: Intent) { L.d("Frost has updated") context.scheduleNotifications(Prefs.notificationFreq) //Update notifications diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Behaviour.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Behaviour.kt index 2af67602..bf524835 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Behaviour.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Behaviour.kt @@ -31,6 +31,10 @@ fun SettingsActivity.getBehaviourPrefs(): KPrefAdapterBuilder.() -> Unit = { descRes = R.string.search_bar_desc } + checkbox(R.string.force_message_bottom, { Prefs.messageScrollToBottom }, { Prefs.messageScrollToBottom = it }) { + descRes = R.string.force_message_bottom_desc + } + checkbox(R.string.exit_confirmation, { Prefs.exitConfirmation }, { Prefs.exitConfirmation = it }) { descRes = R.string.exit_confirmation_desc } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt new file mode 100644 index 00000000..f8dc81d1 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt @@ -0,0 +1,151 @@ +package com.pitchedapps.frost.settings + +import android.content.Context +import android.support.annotation.UiThread +import ca.allanwang.kau.email.sendEmail +import ca.allanwang.kau.kpref.activity.KPrefAdapterBuilder +import ca.allanwang.kau.utils.string +import com.afollestad.materialdialogs.MaterialDialog +import com.pitchedapps.frost.R +import com.pitchedapps.frost.activities.SettingsActivity +import com.pitchedapps.frost.facebook.FACEBOOK_COM +import com.pitchedapps.frost.facebook.FbCookie +import com.pitchedapps.frost.facebook.FbItem +import com.pitchedapps.frost.facebook.USER_AGENT_BASIC +import com.pitchedapps.frost.injectors.InjectorContract +import com.pitchedapps.frost.injectors.JsActions +import com.pitchedapps.frost.utils.L +import com.pitchedapps.frost.utils.cleanHtml +import com.pitchedapps.frost.utils.materialDialogThemed +import com.pitchedapps.frost.web.launchHeadlessHtmlExtractor +import com.pitchedapps.frost.web.query +import io.reactivex.disposables.Disposable +import org.jetbrains.anko.AnkoAsyncContext +import org.jetbrains.anko.doAsync +import org.jetbrains.anko.runOnUiThread +import org.jetbrains.anko.uiThread +import org.jsoup.Jsoup +import org.jsoup.nodes.Document + +/** + * Created by Allan Wang on 2017-06-30. + * + * A sub pref section that is enabled through a hidden preference + * Each category will load a page, extract the contents, remove private info, and create a report + */ +fun SettingsActivity.getDebugPrefs(): KPrefAdapterBuilder.() -> Unit = { + + plainText(R.string.experimental_disclaimer) { + descRes = R.string.debug_disclaimer_info + } + + Debugger.values().forEach { + plainText(it.data.titleId) { + iicon = it.data.icon + onClick = { itemView, _, _ -> it.debug(itemView.context); true } + } + } + +} + +private enum class Debugger(val data: FbItem, val injector: InjectorContract?, vararg query: String) { + NOTIFICATIONS(FbItem.NOTIFICATIONS, null, "#notifications_list"), + SEARCH(FbItem.SEARCH, JsActions.FETCH_BODY); + + val query = if (query.isNotEmpty()) arrayOf(*query, "#root", "main", "body") else emptyArray() + + fun debug(context: Context) { + val dialog = context.materialDialogThemed { + title("Debugging") + progress(true, 0) + canceledOnTouchOutside(false) + positiveText(R.string.kau_cancel) + onPositive { dialog, _ -> dialog.cancel() } + } + if (injector != null) dialog.extractHtml(injector) + else dialog.debugAsync { + loadJsoup() + } + } + + fun MaterialDialog.debugAsync(task: AnkoAsyncContext.() -> Unit) { + doAsync({ t: Throwable -> + val msg = t.message + L.e("Debugger failed: $msg") + context.runOnUiThread { + cancel() + context.materialDialogThemed { + title(R.string.debug_incomplete) + if (msg != null) content(msg) + } + } + }, task) + } + + /** + * Wait for html to be returned from headless webview + * + * from [debug] to [simplifyJsoup] if [query] is not empty, or [createReport] otherwise + */ + @UiThread + private fun MaterialDialog.extractHtml(injector: InjectorContract) { + setContent("Fetching webpage") + var disposable: Disposable? = null + setOnCancelListener { disposable?.dispose() } + context.launchHeadlessHtmlExtractor(data.url, injector) { + disposable = it.subscribe { + (html, errorRes) -> + debugAsync { + if (errorRes == -1) { + L.i("Debug report successful", html) + if (query.isNotEmpty()) simplifyJsoup(Jsoup.parseBodyFragment(html)) + else createReport(html) + } else { + throw Throwable(context.string(errorRes)) + } + } + } + } + } + + /** + * Get data directly from the link and search for our queries, returning the outerHTML + * of the first query found + * + * from [debug] to [simplifyJsoup] + */ + private fun AnkoAsyncContext.loadJsoup() { + uiThread { + it.setContent("Load Jsoup") + it.setOnCancelListener(null) + it.debugAsync { + val connection = Jsoup.connect(data.url).cookie(FACEBOOK_COM, FbCookie.webCookie).userAgent(USER_AGENT_BASIC) + val doc = connection.get() + simplifyJsoup(doc) + } + } + } + + /** + * Takes snippet of given document that matches the first query in the [query] items + * before sending it to [createReport] + */ + private fun AnkoAsyncContext.simplifyJsoup(doc: Document) { + weakRef.get() ?: return + val q = query.first { doc.select(it).isNotEmpty() } + createReport(doc.select(q).outerHtml()) + } + + private fun AnkoAsyncContext.createReport(html: String) { + val cleanHtml = html.cleanHtml() + uiThread { + val c = it.context + it.dismiss() + c.sendEmail(c.string(R.string.dev_email), + "${c.string(R.string.debug_report_email_title)} $name") { + addItem("Query List", query.contentToString()) + footer = cleanHtml + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Experimental.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Experimental.kt index 594cbe01..a1b459fb 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Experimental.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Experimental.kt @@ -1,9 +1,11 @@ package com.pitchedapps.frost.settings import ca.allanwang.kau.kpref.activity.KPrefAdapterBuilder +import ca.allanwang.kau.logging.KL import com.pitchedapps.frost.R import com.pitchedapps.frost.activities.MainActivity import com.pitchedapps.frost.activities.SettingsActivity +import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.Showcase @@ -22,13 +24,17 @@ fun SettingsActivity.getExperimentalPrefs(): KPrefAdapterBuilder.() -> Unit = { // Experimental content starts here ------------------ - checkbox(R.string.notification_messages, { Prefs.notificationsInstantMessages }, { Prefs.notificationsInstantMessages = it }) { - descRes = R.string.notification_messages_desc - } + // Experimental content ends here -------------------- - checkbox(R.string.verbose_logging, { Prefs.verboseLogging }, { Prefs.verboseLogging = it }) { + checkbox(R.string.verbose_logging, { Prefs.verboseLogging }, { + Prefs.verboseLogging = it + KL.debug(it) + KL.showPrivateText = false + L.debug(it) + KL.showPrivateText = false + }) { descRes = R.string.verbose_logging_desc } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Network.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Network.kt new file mode 100644 index 00000000..30ab2579 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Network.kt @@ -0,0 +1,17 @@ +package com.pitchedapps.frost.settings + +import ca.allanwang.kau.kpref.activity.KPrefAdapterBuilder +import com.pitchedapps.frost.R +import com.pitchedapps.frost.activities.SettingsActivity +import com.pitchedapps.frost.utils.Prefs + +/** + * Created by Allan Wang on 2017-08-08. + */ +fun SettingsActivity.getNetworkPrefs(): KPrefAdapterBuilder.() -> Unit = { + + checkbox(R.string.network_media_on_metered, { Prefs.loadMediaOnMeteredNetwork }, { Prefs.loadMediaOnMeteredNetwork = it }) { + descRes = R.string.network_media_on_metered_desc + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt index a5aa84d3..b1e5015f 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt @@ -55,6 +55,10 @@ fun SettingsActivity.getNotificationPrefs(): KPrefAdapterBuilder.() -> Unit = { descRes = R.string.notification_all_accounts_desc } + checkbox(R.string.notification_messages, { Prefs.notificationsInstantMessages }, { Prefs.notificationsInstantMessages = it }) { + descRes = R.string.notification_messages_desc + } + checkbox(R.string.notification_sound, { Prefs.notificationSound }, { Prefs.notificationSound = it }) checkbox(R.string.notification_vibrate, { Prefs.notificationVibrate }, { Prefs.notificationVibrate = it }) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/JsoupCleaner.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/JsoupCleaner.kt new file mode 100644 index 00000000..da8672f4 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/JsoupCleaner.kt @@ -0,0 +1,34 @@ +package com.pitchedapps.frost.utils + +import org.jsoup.Jsoup +import org.jsoup.nodes.Attribute +import org.jsoup.nodes.Element +import org.jsoup.safety.Whitelist + +/** + * Created by Allan Wang on 2017-08-10. + * + * Parses html with Jsoup and cleans the data, emitting just the frame containing debugging info + * + * Removes text, removes unnecessary nodes + */ +fun String.cleanHtml() = cleanText().cleanJsoup() + +internal fun String.cleanText(): String = replace(Regex(">(?s).+?<"), "><") + +internal fun String.cleanJsoup(): String = Jsoup.clean(this, PrivacyWhitelist()) + +class PrivacyWhitelist : Whitelist() { + + val blacklistAttrs = arrayOf("style", "aria-label", "rel") + val blacklistTags = arrayOf("body", "html", "head", "i", "b", "u", "style", "script", + "br", "p", "span", "ul", "ol", "li") + + override fun isSafeAttribute(tagName: String, el: Element, attr: Attribute): Boolean { + val key = attr.key + if (key == "href") attr.setValue("-") + return key !in blacklistAttrs + } + + override fun isSafeTag(tag: String) = tag !in blacklistTags +} diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt index 16a3d2ae..d5c1a6fb 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt @@ -1,9 +1,8 @@ package com.pitchedapps.frost.utils -import android.util.Log -import ca.allanwang.kau.logging.TimberLogger +import ca.allanwang.kau.logging.KauLogger import com.crashlytics.android.Crashlytics -import timber.log.Timber +import com.pitchedapps.frost.BuildConfig /** @@ -16,25 +15,15 @@ import timber.log.Timber * Debug and Error logs must not reveal person info * Person info logs can be marked as info or verbose */ -object L : TimberLogger("Frost") { +object L : KauLogger("Frost") { - /** - * Helper function to separate private info - */ - fun d(tag: String, personal: String?) { - L.d(tag) - L.i("-\t$personal") - } -} - -internal class CrashReportingTree : Timber.Tree() { - override fun log(priority: Int, tag: String?, message: String?, t: Throwable?) { - when (priority) { - Log.VERBOSE, Log.INFO -> return - Log.DEBUG -> if (!Prefs.verboseLogging) return + override fun logImpl(priority: Int, message: String?, privateMessage: String?, t: Throwable?) { + if (BuildConfig.DEBUG) { + super.logImpl(priority, message, privateMessage, t) + } else { + if (message != null) + Crashlytics.log(priority, "Frost", message) + if (t != null) Crashlytics.logException(t) } - if (message != null) - Crashlytics.log(priority, "Frost", message) - if (t != null) Crashlytics.logException(t) } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/Prefs.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/Prefs.kt index b053b9dd..9b8064a4 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/Prefs.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/Prefs.kt @@ -97,8 +97,7 @@ object Prefs : KPref() { var notificationAllAccounts: Boolean by kpref("notification_all_accounts", true) - //todo remove from experimental once stabilized - var notificationsInstantMessages: Boolean by kpref("notification_im", Showcase.experimentalDefault) + var notificationsInstantMessages: Boolean by kpref("notification_im", false) var notificationVibrate: Boolean by kpref("notification_vibrate", true) @@ -106,6 +105,8 @@ object Prefs : KPref() { var notificationLights: Boolean by kpref("notification_lights", true) + var messageScrollToBottom: Boolean by kpref("message_scroll_to_bottom", false) + /** * Cache like value to determine if user has or had pro * In most cases, [com.pitchedapps.frost.utils.iab.IS_FROST_PRO] should be looked at instead @@ -128,4 +129,8 @@ object Prefs : KPref() { var viewpagerSwipe: Boolean by kpref("viewpager_swipe", true) + var loadMediaOnMeteredNetwork: Boolean by kpref("media_on_metered_network", true) + + var debugSettings: Boolean by kpref("debug_settings", false) + } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt index 496a6b5b..e79816f3 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt @@ -25,7 +25,7 @@ import com.pitchedapps.frost.R import com.pitchedapps.frost.activities.* import com.pitchedapps.frost.dbflow.CookieModel import com.pitchedapps.frost.facebook.FACEBOOK_COM -import com.pitchedapps.frost.facebook.FbTab +import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.formattedFbUrl import java.io.IOException import java.util.* @@ -56,8 +56,8 @@ fun Activity.cookies(): ArrayList { fun Context.launchWebOverlay(url: String) { val argUrl = url.formattedFbUrl - L.v("Launch received $url") - L.i("Launch web overlay: $argUrl") + L.v("Launch received", url) + L.i("Launch web overlay", argUrl) startActivity(WebOverlayActivity::class.java, false, intentBuilder = { putExtra(ARG_URL, argUrl) }) @@ -74,7 +74,7 @@ fun Activity.launchIntroActivity(cookieList: ArrayList) = launchNewTask(IntroActivity::class.java, cookieList, true) fun WebOverlayActivity.url(): String { - return intent.extras?.getString(ARG_URL) ?: FbTab.FEED.url + return intent.extras?.getString(ARG_URL) ?: FbItem.FEED.url } fun Context.materialDialogThemed(action: MaterialDialog.Builder.() -> Unit): MaterialDialog { @@ -132,6 +132,15 @@ fun frostAnswersCustom(name: String, vararg events: Pair) { } } +/** + * Helper method to quietly keep track of throwable issues + */ +fun Throwable?.logFrostAnswers(text: String) { + val msg = if (this == null) text else "$text: $message" + L.e(msg) + frostAnswersCustom("Errors", "text" to text, "message" to (this?.message ?: "NA")) +} + fun View.frostSnackbar(@StringRes text: Int, builder: Snackbar.() -> Unit = {}) { Snackbar.make(this, text, Snackbar.LENGTH_LONG).apply { builder() diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/iab/IABBinder.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/iab/IABBinder.kt index bad7f8fd..7f6e8a6d 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/iab/IABBinder.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/iab/IABBinder.kt @@ -9,6 +9,13 @@ import com.pitchedapps.frost.BuildConfig import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.frostAnswers +import com.pitchedapps.frost.utils.logFrostAnswers +import org.jetbrains.anko.doAsync +import org.jetbrains.anko.onComplete +import org.jetbrains.anko.uiThread +import java.lang.ref.WeakReference +import java.math.BigDecimal +import java.util.* /** * Created by Allan Wang on 2017-07-22. @@ -33,31 +40,48 @@ interface FrostBilling : BillingProcessor.IBillingHandler { abstract class IABBinder : FrostBilling { var bp: BillingProcessor? = null - var activity: Activity? = null - - override fun Activity.onCreateBilling() { - activity = this - bp = BillingProcessor.newBillingProcessor(this, PUBLIC_BILLING_KEY, this@IABBinder) - bp?.initialize() + lateinit var activityRef: WeakReference + val activity + get() = activityRef.get() + + override final fun Activity.onCreateBilling() { + activityRef = WeakReference(this) + doAsync { + bp = BillingProcessor.newBillingProcessor(this@onCreateBilling, PUBLIC_BILLING_KEY, this@IABBinder) + bp?.initialize() + } } override fun onDestroyBilling() { + activityRef.clear() bp?.release() bp = null - activity = null } - override fun onBillingInitialized() = L.d("IAB initialized") + override fun onBillingInitialized() = L.i("IAB initialized") override fun onPurchaseHistoryRestored() = L.d("IAB restored") override fun onProductPurchased(productId: String, details: TransactionDetails?) { - L.d("IAB $productId purchased") - frostAnswers { - logPurchase(PurchaseEvent() - .putItemId(productId) - .putSuccess(true) - ) + bp.doAsync { + L.i("IAB $productId purchased") + val listing = weakRef.get()?.getPurchaseListingDetails(productId) ?: return@doAsync + val currency = try { + Currency.getInstance(listing.currency) + } catch (e: Exception) { + null + } + frostAnswers { + logPurchase(PurchaseEvent().apply { + putItemId(productId) + putSuccess(true) + if (currency != null) { + putCurrency(Currency.getInstance(Locale.getDefault())) + putItemType(productId) + putItemPrice(BigDecimal.valueOf(listing.priceValue)) + } + }) + } } } @@ -67,13 +91,14 @@ abstract class IABBinder : FrostBilling { .putCustomAttribute("result", errorCode.toString()) .putSuccess(false)) } - L.e(error, "IAB error $errorCode") + error.logFrostAnswers("IAB error $errorCode") } override fun onActivityResultBilling(requestCode: Int, resultCode: Int, data: Intent?): Boolean = bp?.handleActivityResult(requestCode, resultCode, data) ?: false override fun purchasePro() { + val bp = this.bp if (bp == null) { frostAnswers { logPurchase(PurchaseEvent() @@ -83,10 +108,12 @@ abstract class IABBinder : FrostBilling { L.eThrow("IAB null bp on purchase attempt") return } - if (!(bp?.isOneTimePurchaseSupported ?: false)) - activity?.playStorePurchaseUnsupported() + val a = activity ?: return + + if (!BillingProcessor.isIabServiceAvailable(a) || !bp.isOneTimePurchaseSupported) + a.playStorePurchaseUnsupported() else - bp?.purchase(activity, FROST_PRO) + bp.purchase(a, FROST_PRO) } } @@ -107,15 +134,18 @@ class IABSettings : IABBinder() { * Attempts to get pro, or launch purchase flow if user doesn't have it */ override fun restorePurchases() { - if (bp == null) return - val load = bp?.loadOwnedPurchasesFromGoogle() ?: return - L.d("IAB settings load from google $load") - if (!(bp?.isPurchased(FROST_PRO) ?: return)) { - if (Prefs.pro) activity.playStoreNoLongerPro() - else purchasePro() - } else { - if (!Prefs.pro) activity.playStoreFoundPro() - else activity?.purchaseRestored() + bp.doAsync { + val load = weakRef.get()?.loadOwnedPurchasesFromGoogle() ?: return@doAsync + L.d("IAB settings load from google $load") + uiThread { + if (!(weakRef.get()?.isPurchased(FROST_PRO) ?: return@uiThread)) { + if (Prefs.pro) activity.playStoreNoLongerPro() + else purchasePro() + } else { + if (!Prefs.pro) activity.playStoreFoundPro() + else activity?.purchaseRestored() + } + } } } } @@ -142,13 +172,17 @@ class IABMain : IABBinder() { override fun restorePurchases() { if (restored || bp == null) return restored = true - val load = bp?.loadOwnedPurchasesFromGoogle() ?: false - L.d("IAB main load from google $load") - if (!(bp?.isPurchased(FROST_PRO) ?: false)) { - if (Prefs.pro) activity.playStoreNoLongerPro() - } else { - if (!Prefs.pro) activity.playStoreFoundPro() + bp.doAsync { + val load = weakRef.get()?.loadOwnedPurchasesFromGoogle() ?: false + L.d("IAB main load from google $load") + onComplete { + if (!(weakRef.get()?.isPurchased(FROST_PRO) ?: false)) { + if (Prefs.pro) activity.playStoreNoLongerPro() + } else { + if (!Prefs.pro) activity.playStoreFoundPro() + } + onDestroyBilling() + } } - onDestroyBilling() } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/iab/IABDialogs.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/iab/IABDialogs.kt index df0f04fd..e997731b 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/iab/IABDialogs.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/iab/IABDialogs.kt @@ -2,6 +2,7 @@ package com.pitchedapps.frost.utils.iab import android.app.Activity import ca.allanwang.kau.utils.restart +import ca.allanwang.kau.utils.startLink import ca.allanwang.kau.utils.startPlayStoreLink import ca.allanwang.kau.utils.string import com.crashlytics.android.answers.PurchaseEvent @@ -69,9 +70,11 @@ fun Activity.playStorePurchaseUnsupported() { materialDialogThemed { title(R.string.uh_oh) content(R.string.play_store_unsupported) - positiveText(R.string.kau_ok) - neutralText(R.string.kau_play_store) - onNeutral { _, _ -> startPlayStoreLink(R.string.play_store_package_id) } + negativeText(R.string.kau_close) + positiveText(R.string.kau_play_store) + neutralText(R.string.paypal) + onPositive { _, _ -> startPlayStoreLink(R.string.play_store_package_id) } + onNeutral { _, _ -> startLink(string(R.string.dev_paypal)) } } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt index 6bc27256..61711092 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt @@ -34,15 +34,9 @@ class FrostChromeClient(webCore: FrostWebViewCore) : WebChromeClient() { val activityContract = (webCore.context as? ActivityWebContract) val context = webCore.context!! - companion object { - val consoleBlacklist = setOf( - "edge-chat" - ) - } - override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { if (consoleBlacklist.any { consoleMessage.message().contains(it) }) return true - L.i("Chrome Console ${consoleMessage.lineNumber()}: ${consoleMessage.message()}") + L.d("Chrome Console ${consoleMessage.lineNumber()}: ${consoleMessage.message()}") return true } @@ -63,10 +57,10 @@ class FrostChromeClient(webCore: FrostWebViewCore) : WebChromeClient() { } override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) { - L.d("Requesting geolocation") + L.i("Requesting geolocation") context.kauRequestPermissions(PERMISSION_ACCESS_FINE_LOCATION) { granted, _ -> - L.d("Geolocation response received; ${if (granted) "granted" else "denied"}") + L.i("Geolocation response received; ${if (granted) "granted" else "denied"}") callback(origin, granted, true) } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt index 018ad737..f24a7a51 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt @@ -81,12 +81,14 @@ class FrostJSI(val webView: FrostWebViewCore) { } @JavascriptInterface - fun handleHtml(html: String) { + fun handleHtml(html: String?) { + html ?: return webView.post { webView.frostWebClient.handleHtml(html) } } @JavascriptInterface - fun handleHeader(html: String) { + fun handleHeader(html: String?) { + html ?: return headerObservable?.onNext(html) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostRequestInterceptor.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostRequestInterceptor.kt index 3f2891d0..1a907f7f 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostRequestInterceptor.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostRequestInterceptor.kt @@ -5,6 +5,7 @@ import android.webkit.WebResourceResponse import android.webkit.WebView import ca.allanwang.kau.utils.use import com.pitchedapps.frost.utils.L +import com.pitchedapps.frost.utils.Prefs import okhttp3.HttpUrl import java.io.ByteArrayInputStream @@ -15,17 +16,17 @@ import java.io.ByteArrayInputStream * Handler to decide when a request should be done by us * This is the crux of Frost's optimizations for the web browser */ -val blankResource: WebResourceResponse by lazy { WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream("".toByteArray())) } +private val blankResource: WebResourceResponse by lazy { WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream("".toByteArray())) } //these hosts will redirect to a blank resource -val blacklistHost: Set by lazy { +private val blacklistHost: Set by lazy { setOf( "edge-chat.facebook.com" ) } //these hosts will return null and skip logging -val whitelistHost: Set by lazy { +private val whitelistHost: Set by lazy { setOf( "static.xx.fbcdn.net", "m.facebook.com", @@ -35,13 +36,13 @@ val whitelistHost: Set by lazy { //these hosts will skip ad inspection //this list does not have to include anything from the two above -val adWhitelistHost: Set by lazy { +private val adWhitelistHost: Set by lazy { setOf( "scontent-sea1-1.xx.fbcdn.net" ) } -var adblock: Set? = null +private var adblock: Set? = null fun shouldFrostInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? { val httpUrl = HttpUrl.parse(request.url?.toString() ?: return null) ?: return null @@ -53,7 +54,8 @@ fun shouldFrostInterceptRequest(view: WebView, request: WebResourceRequest): Web if (adblock == null) adblock = view.context.assets.open("adblock.txt").bufferedReader().use { it.readLines().toSet() } if (adblock?.any { url.contains(it) } ?: false) return blankResource } - L.v("Intercept Request ${host} ${url}") + if (!shouldLoadImages && !Prefs.loadMediaOnMeteredNetwork && request.isMedia) return blankResource + L.v("Intercept Request", "$host $url") return null } @@ -64,16 +66,25 @@ fun WebResourceRequest.query(action: (url: String) -> Boolean): Boolean { return action(url?.path ?: return false) } +val WebResourceRequest.isImage: Boolean + get() = query { it.contains(".jpg") || it.contains(".png") } + +val WebResourceRequest.isMedia: Boolean + get() = query { it.contains(".jpg") || it.contains(".png") || it.contains("video") } + /** * Generic filter passthrough * If Resource is already nonnull, pass it, otherwise check if filter is met and override the response accordingly */ -fun WebResourceResponse?.filter(request: WebResourceRequest, filter: (url: String) -> Boolean): WebResourceResponse? - = this ?: if (request.query { filter(it) }) blankResource else null +fun WebResourceResponse?.filter(request: WebResourceRequest, filter: (url: String) -> Boolean) + = filter(request.query { filter(it) }) + +fun WebResourceResponse?.filter(filter: Boolean): WebResourceResponse? + = this ?: if (filter) blankResource else null fun WebResourceResponse?.filterCss(request: WebResourceRequest): WebResourceResponse? = filter(request) { it.endsWith(".css") } fun WebResourceResponse?.filterImage(request: WebResourceRequest): WebResourceResponse? - = filter(request) { it.contains(".jpg") || it.contains(".png") } + = filter(request.isImage) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebView.kt index 79ca1fdf..89ad766d 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebView.kt @@ -11,7 +11,7 @@ import android.widget.FrameLayout import android.widget.ProgressBar import ca.allanwang.kau.utils.* import com.pitchedapps.frost.R -import com.pitchedapps.frost.facebook.FbTab +import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.USER_AGENT_BASIC import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.frostDownload @@ -53,7 +53,7 @@ class FrostWebView @JvmOverloads constructor( } @SuppressLint("SetJavaScriptEnabled") - fun setupWebview(url: String, enum: FbTab? = null) { + fun setupWebview(url: String, enum: FbItem? = null) { with(web) { baseUrl = url baseEnum = enum @@ -77,7 +77,7 @@ class FrostWebView @JvmOverloads constructor( //Some urls have postJavascript injections so make sure we load the base url override fun onRefresh() { when (web.baseUrl) { - FbTab.MENU.url -> web.loadBaseUrl(true) + FbItem.MENU.url -> web.loadBaseUrl(true) else -> web.reload(true) } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt index 94bff3c3..5f679c65 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt @@ -1,8 +1,8 @@ package com.pitchedapps.frost.web import android.content.Context -import android.content.Intent import android.graphics.Bitmap +import android.graphics.Color import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView @@ -14,10 +14,12 @@ import com.pitchedapps.frost.activities.WebOverlayActivity import com.pitchedapps.frost.facebook.FACEBOOK_COM import com.pitchedapps.frost.facebook.FB_URL_BASE import com.pitchedapps.frost.facebook.FbCookie +import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.injectors.* import com.pitchedapps.frost.utils.* import com.pitchedapps.frost.utils.iab.IS_FROST_PRO import io.reactivex.subjects.Subject +import org.jetbrains.anko.withAlpha /** * Created by Allan Wang on 2017-05-31. @@ -42,18 +44,19 @@ open class BaseWebViewClient : WebViewClient() { open class FrostWebViewClient(val webCore: FrostWebViewCore) : BaseWebViewClient() { val refreshObservable: Subject = webCore.refreshObservable + val isMain = webCore.baseEnum != null override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) if (url == null) return - L.i("FWV Loading $url") -// L.v("Cookies ${CookieManager.getInstance().getCookie(url)}") + L.i("FWV Loading", url) refreshObservable.onNext(true) if (!url.contains(FACEBOOK_COM)) return if (url.contains("logout.php")) FbCookie.logout(Prefs.userId, { launchLogin(view.context) }) else if (url.contains("login.php")) FbCookie.reset({ launchLogin(view.context) }) } + fun launchLogin(c: Context) { if (c is MainActivity && c.cookies().isNotEmpty()) c.launchNewTask(SelectorActivity::class.java, c.cookies()) @@ -61,44 +64,52 @@ open class FrostWebViewClient(val webCore: FrostWebViewCore) : BaseWebViewClient c.launchNewTask(LoginActivity::class.java) } + fun injectBackgroundColor() + = webCore.setBackgroundColor(if (isMain) Color.TRANSPARENT else Prefs.bgColor.withAlpha(255)) + + + override fun onPageCommitVisible(view: WebView, url: String?) { + super.onPageCommitVisible(view, url) + injectBackgroundColor() + view.jsInject( + CssAssets.ROUND_ICONS.maybe(Prefs.showRoundedIcons), + CssHider.HEADER, + CssHider.PEOPLE_YOU_MAY_KNOW.maybe(!Prefs.showSuggestedFriends && IS_FROST_PRO), + Prefs.themeInjector, + CssHider.NON_RECENT.maybe(webCore.url?.contains("?sk=h_chr") ?: false)) + } + override fun onPageFinished(view: WebView, url: String?) { - super.onPageFinished(view, url) - if (url == null) return - L.i("Page finished $url") + url ?: return + L.i("Page finished", url) if (!url.contains(FACEBOOK_COM)) { refreshObservable.onNext(false) return } - view.jsInject( - CssAssets.ROUND_ICONS.maybe(Prefs.showRoundedIcons), - CssHider.PEOPLE_YOU_MAY_KNOW.maybe(!Prefs.showSuggestedFriends && IS_FROST_PRO), - CssHider.ADS.maybe(!Prefs.showFacebookAds && IS_FROST_PRO) - ) onPageFinishedActions(url) } open internal fun onPageFinishedActions(url: String) { + if (url.startsWith("${FbItem.MESSAGES.url}/read/") && Prefs.messageScrollToBottom) + webCore.pageDown(true) injectAndFinish() } internal fun injectAndFinish() { L.d("Page finished reveal") - webCore.jsInject(CssHider.HEADER, - CssHider.NON_RECENT.maybe(webCore.url.contains("?sk=h_chr")), - Prefs.themeInjector, - callback = { - refreshObservable.onNext(false) - webCore.jsInject( - JsActions.LOGIN_CHECK, - JsAssets.CLICK_A.maybe(webCore.baseEnum != null && Prefs.overlayEnabled), - JsAssets.TEXTAREA_LISTENER, - JsAssets.CONTEXT_A, - JsAssets.HEADER_BADGES.maybe(webCore.baseEnum != null) - ) - }) - } - - open fun handleHtml(html: String) { + refreshObservable.onNext(false) + injectBackgroundColor() + webCore.jsInject( + JsActions.LOGIN_CHECK, + JsAssets.CLICK_A.maybe(webCore.baseEnum != null && Prefs.overlayEnabled), + JsAssets.TEXTAREA_LISTENER, + CssHider.ADS.maybe(!Prefs.showFacebookAds && IS_FROST_PRO), + JsAssets.CONTEXT_A, + JsAssets.HEADER_BADGES.maybe(webCore.baseEnum != null) + ) + } + + open fun handleHtml(html: String?) { L.d("Handle Html") } @@ -112,26 +123,26 @@ open class FrostWebViewClient(val webCore: FrostWebViewCore) : BaseWebViewClient * returns false if we are already in an overlaying activity */ private fun launchRequest(request: WebResourceRequest): Boolean { - L.d("Launching Url", request.url.toString()) + L.d("Launching Url", request.url?.toString() ?: "null") if (webCore.context is WebOverlayActivity) return false webCore.context.launchWebOverlay(request.url.toString()) return true } - private fun launchImage(request: WebResourceRequest, text: String? = null): Boolean { - L.d("Launching Image", request.url.toString()) - webCore.context.launchImageActivity(request.url.toString(), text) + private fun launchImage(url: String, text: String? = null): Boolean { + L.d("Launching Image", url) + webCore.context.launchImageActivity(url, text) if (webCore.canGoBack()) webCore.goBack() return true } override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { - L.i("Url Loading ${request.url}") - val path = request.url.path ?: return super.shouldOverrideUrlLoading(view, request) - L.v("Url Loading Path $path") + L.i("Url Loading", request.url?.toString()) + val path = request.url?.path ?: return super.shouldOverrideUrlLoading(view, request) + L.v("Url Loading Path", path) if (path.startsWith("/composer/")) return launchRequest(request) if (request.url.toString().contains("scontent-sea1-1.xx.fbcdn.net") && (path.endsWith(".jpg") || path.endsWith(".png"))) - return launchImage(request) + return launchImage(request.url.toString()) if (view.context.resolveActivityForUri(request.url)) return true return super.shouldOverrideUrlLoading(view, request) } @@ -163,6 +174,7 @@ class FrostWebViewClientMenu(webCore: FrostWebViewCore) : FrostWebViewClient(web } override fun onPageFinishedActions(url: String) { + L.d("Should inject ${url.shouldInjectMenu}") if (!url.shouldInjectMenu) injectAndFinish() } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewCore.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewCore.kt index d8edc15c..6dbc7c8d 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewCore.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewCore.kt @@ -14,7 +14,7 @@ import ca.allanwang.kau.utils.circularReveal import ca.allanwang.kau.utils.fadeIn import ca.allanwang.kau.utils.fadeOut import ca.allanwang.kau.utils.isVisible -import com.pitchedapps.frost.facebook.FbTab +import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.utils.Prefs import io.reactivex.Scheduler import io.reactivex.android.schedulers.AndroidSchedulers @@ -41,7 +41,7 @@ class FrostWebViewCore @JvmOverloads constructor( var baseUrl: String? = null - var baseEnum: FbTab? = null //only viewpager items should pass the base enum + var baseEnum: FbItem? = null //only viewpager items should pass the base enum internal lateinit var frostWebClient: FrostWebViewClient init { @@ -76,7 +76,7 @@ class FrostWebViewCore @JvmOverloads constructor( if (isVisible) fadeOut(duration = 200L) } else if (loading) { dispose?.dispose() - if (animate && Prefs.animate) circularReveal(offset = 150L) + if (animate && Prefs.animate) circularReveal(offset = WEB_LOAD_DELAY) else fadeIn(duration = 100L) } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/HeadlessHtmlExtractor.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/HeadlessHtmlExtractor.kt new file mode 100644 index 00000000..50f2f6bc --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/HeadlessHtmlExtractor.kt @@ -0,0 +1,88 @@ +package com.pitchedapps.frost.web + +import android.annotation.SuppressLint +import android.content.Context +import android.webkit.JavascriptInterface +import android.webkit.WebView +import ca.allanwang.kau.utils.gone +import com.pitchedapps.frost.R +import com.pitchedapps.frost.facebook.USER_AGENT_BASIC +import com.pitchedapps.frost.injectors.InjectorContract +import com.pitchedapps.frost.utils.L +import io.reactivex.Single +import io.reactivex.SingleEmitter +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import org.jetbrains.anko.runOnUiThread +import java.util.concurrent.TimeUnit + +/** + * Created by Allan Wang on 2017-08-12. + * + * Launches a headless html request and returns a result pair + * When successful, the pair will contain the html content and -1 + * When unsuccessful, the pair will contain an empty string and a StringRes for the given error + * + * All errors are rerouted to success calls, so no exceptions should occur. + * The headless extractor will also destroy itself on cancellation or when the request is finished + */ +fun Context.launchHeadlessHtmlExtractor(url: String, injector: InjectorContract, action: (Single>) -> Unit) { + val single = Single.create> { e: SingleEmitter> -> + val extractor = HeadlessHtmlExtractor(this, url, injector, e) + e.setCancellable { + runOnUiThread { extractor.destroy() } + e.onSuccess("" to R.string.html_extraction_cancelled) + } + }.subscribeOn(AndroidSchedulers.mainThread()) + .timeout(20, TimeUnit.SECONDS, Schedulers.io(), { it.onSuccess("" to R.string.html_extraction_timeout) }) + .onErrorReturn { "" to R.string.html_extraction_error } + action(single) +} + +/** + * Given a link and some javascript, will load the link and load the JS on completion + * The JS is expected to call [HeadlessHtmlExtractor.HtmlJSI.handleHtml], which will be sent + * to the [emitter] + */ +@SuppressLint("ViewConstructor") +private class HeadlessHtmlExtractor( + context: Context, url: String, val injector: InjectorContract, val emitter: SingleEmitter> +) : WebView(context) { + + val startTime = System.currentTimeMillis() + + init { + L.v("Created HeadlessHtmlExtractor for $url") + gone() + setupWebview(url) + } + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebview(url: String) { + settings.javaScriptEnabled = true + settings.userAgentString = USER_AGENT_BASIC + webViewClient = HeadlessWebViewClient(url, injector) // basic client that loads our JS once the page has loaded + webChromeClient = QuietChromeClient() // basic client that disables logging + addJavascriptInterface(HtmlJSI(), "Frost") + loadUrl(url) + } + + inner class HtmlJSI { + @JavascriptInterface + fun handleHtml(html: String?) { + val time = System.currentTimeMillis() - startTime + emitter.onSuccess((html ?: "") to -1) + post { + L.d("HeadlessHtmlExtractor fetched $url in $time ms") + settings.javaScriptEnabled = false + settings.blockNetworkLoads = true + destroy() + } + } + } + + override fun destroy() { + super.destroy() + L.d("HeadlessHtmlExtractor destroyed") + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt index 31be4450..aea25337 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt @@ -60,7 +60,7 @@ class LoginWebView @JvmOverloads constructor( view.jsInject(CssHider.HEADER.maybe(containsFacebook), CssHider.CORE.maybe(containsFacebook), Prefs.themeInjector.maybe(containsFacebook), - callback = { if (!view.isVisible) view.fadeIn(offset = 150L) }) + callback = { if (!view.isVisible) view.fadeIn(offset = WEB_LOAD_DELAY) }) } fun checkForLogin(url: String?, onFound: (id: Long, cookie: String) -> Unit) { diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/MessageWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/MessageWebView.kt deleted file mode 100644 index 53fa0657..00000000 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/MessageWebView.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.pitchedapps.frost.web - -import android.annotation.SuppressLint -import android.app.job.JobParameters -import android.webkit.JavascriptInterface -import android.webkit.WebView -import ca.allanwang.kau.utils.gone -import com.pitchedapps.frost.dbflow.CookieModel -import com.pitchedapps.frost.facebook.FbTab -import com.pitchedapps.frost.facebook.USER_AGENT_BASIC -import com.pitchedapps.frost.injectors.JsAssets -import com.pitchedapps.frost.services.NotificationService -import com.pitchedapps.frost.utils.L -import com.pitchedapps.frost.utils.frostAnswersCustom -import org.jetbrains.anko.doAsync - -/** - * Created by Allan Wang on 2017-07-17. - * - * Bare boned headless view made solely to extract conversation info - */ -@SuppressLint("ViewConstructor") -class MessageWebView(val service: NotificationService, val params: JobParameters?, val cookie: CookieModel) : WebView(service) { - - private val startTime = System.currentTimeMillis() - private var isCancelled = false - - init { - gone() - setupWebview() - } - - @SuppressLint("SetJavaScriptEnabled") - private fun setupWebview() { - settings.javaScriptEnabled = true - settings.userAgentString = USER_AGENT_BASIC - webViewClient = HeadlessWebViewClient("MessageNotifs", JsAssets.NOTIF_MSG) - webChromeClient = QuietChromeClient() - addJavascriptInterface(MessageJSI(), "Frost") - loadUrl(FbTab.MESSAGES.url) - } - - fun finish() { - if (isCancelled) return - isCancelled = true - post { destroy() } - service.finish(params) - } - - override fun destroy() { - L.d("MessageWebView destroyed") - super.destroy() - } - - inner class MessageJSI { - @JavascriptInterface - fun handleHtml(html: String) { - if (isCancelled) return - if (html.length < 10) return finish() - val time = System.currentTimeMillis() - startTime - L.d("Notif messages fetched in $time ms") - frostAnswersCustom("NotificationTime", "Type" to "IM Headless", "Duration" to time) - doAsync { service.fetchMessageNotifications(cookie, html); finish() } - } - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/SearchWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/SearchWebView.kt index 05d56f92..da6d8ad3 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/SearchWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/SearchWebView.kt @@ -6,7 +6,7 @@ import android.webkit.JavascriptInterface import android.webkit.WebView import ca.allanwang.kau.searchview.SearchItem import ca.allanwang.kau.utils.gone -import com.pitchedapps.frost.facebook.FbTab +import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.USER_AGENT_BASIC import com.pitchedapps.frost.injectors.JsAssets import com.pitchedapps.frost.injectors.JsBuilder @@ -27,7 +27,7 @@ import java.util.concurrent.TimeUnit */ class SearchWebView(context: Context, val contract: SearchContract) : WebView(context) { - val searchSubject = PublishSubject.create() + val searchSubject = PublishSubject.create()!! init { gone() @@ -39,11 +39,11 @@ class SearchWebView(context: Context, val contract: SearchContract) : WebView(co * Contains the last item's href (search more) as well as the number of items found * This holder is synchronized */ - var previousResult: Pair = Pair(null, 0) + var previousResult: Pair = Pair("", 0) fun saveResultFrame(result: List, String>>) { synchronized(previousResult) { - previousResult = Pair(result.lastOrNull()?.second, result.size) + previousResult = Pair(result.last().second, result.size) } } @@ -56,17 +56,22 @@ class SearchWebView(context: Context, val contract: SearchContract) : WebView(co addJavascriptInterface(SearchJSI(), "Frost") searchSubject.debounce(300, TimeUnit.MILLISECONDS).subscribeOn(Schedulers.newThread()) .map { - Jsoup.parse(it).select("a:not([rel*='keywords(']):not([href=#])[rel]").map { + val doc = Jsoup.parse(it) + L.d(doc.getElementById("main-search_input")?.html()) + val searchQuery = doc.getElementById("main-search-input")?.text() ?: "Null input" + L.d("Search query", searchQuery) + doc.select("a:not([rel*='keywords(']):not([href=#])[rel]").map { element -> //split text into separate items - L.v("Search element ${element.attr("href")}") - val texts = element.select("div").map { (it.text()) }.filter { it.isNotBlank() } + L.v("Search element", element.attr("href")) + val texts = element.select("div").map { it.text() }.filter { !it.isNullOrBlank() } val pair = Pair(texts, element.attr("href")) - L.v("Search element potential $pair") + L.v("Search element potential", pair.toString()) pair }.filter { it.first.isNotEmpty() } } - .filter { content -> Pair(content.lastOrNull()?.second, content.size) != previousResult } + .filter { it.isNotEmpty() } + .filter { Pair(it.last().second, it.size) != previousResult } .subscribe { content: List, String>> -> saveResultFrame(content) @@ -90,7 +95,7 @@ class SearchWebView(context: Context, val contract: SearchContract) : WebView(co } override fun reload() { - super.loadUrl(FbTab.SEARCH.url) + super.loadUrl(FbItem.SEARCH.url) } /** @@ -104,7 +109,8 @@ class SearchWebView(context: Context, val contract: SearchContract) : WebView(co inner class SearchJSI { @JavascriptInterface - fun handleHtml(html: String) { + fun handleHtml(html: String?) { + html ?: return L.d("Search received response ${contract.isSearchOpened}") if (!contract.isSearchOpened) pauseLoad = true searchSubject.onNext(html) @@ -117,11 +123,14 @@ class SearchWebView(context: Context, val contract: SearchContract) : WebView(co L.d("Search loaded successfully") } 1 -> { //something is not found in the search view; this is effectively useless - L.eThrow("Search subject error; reverting to full overlay") + L.e("Search subject error; reverting to full overlay") Prefs.searchBar = false searchSubject.onComplete() contract.searchOverlayDispose() } + 2 -> { + L.v("Search emission received") + } } } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/WebStates.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/WebStates.kt new file mode 100644 index 00000000..ad1fe467 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/WebStates.kt @@ -0,0 +1,11 @@ +package com.pitchedapps.frost.web + +/** + * Created by Allan Wang on 2017-08-08. + * + * Global variables that are define states or constants for web contents + */ +const val WEB_LOAD_DELAY = 50L +var shouldLoadImages = false + +val consoleBlacklist = setOf("edge-chat") \ No newline at end of file -- cgit v1.2.3