From 397cc1f54725aee6fb542b08f965389272469309 Mon Sep 17 00:00:00 2001 From: Allan Wang Date: Mon, 7 Aug 2017 23:04:40 -0700 Subject: Update/kau (#125) * Update logger * Clean imports and bring back reactive libs * Update dependencies and make billing async --- .../main/kotlin/com/pitchedapps/frost/FrostApp.kt | 12 ++- .../pitchedapps/frost/activities/AboutActivity.kt | 7 -- .../pitchedapps/frost/activities/ImageActivity.kt | 6 +- .../pitchedapps/frost/activities/MainActivity.kt | 12 +-- .../frost/activities/SettingsActivity.kt | 4 +- .../com/pitchedapps/frost/contracts/FileChooser.kt | 2 - .../com/pitchedapps/frost/settings/Experimental.kt | 10 ++- .../main/kotlin/com/pitchedapps/frost/utils/L.kt | 31 +++----- .../com/pitchedapps/frost/utils/iab/IABBinder.kt | 88 +++++++++++++++------- .../kotlin/com/pitchedapps/frost/web/FrostJSI.kt | 6 +- .../pitchedapps/frost/web/FrostWebViewClients.kt | 3 +- .../com/pitchedapps/frost/web/MessageWebView.kt | 3 +- .../com/pitchedapps/frost/web/SearchWebView.kt | 14 ++-- app/src/main/res/xml/frost_changelog.xml | 2 +- .../test/kotlin/com/pitchedapps/frost/MiscTest.kt | 8 +- 15 files changed, 115 insertions(+), 93 deletions(-) (limited to 'app/src') diff --git a/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt b/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt index 371f9c33..27c2106a 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt @@ -4,6 +4,7 @@ import android.app.Application import android.graphics.drawable.Drawable import android.net.Uri 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 +13,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,14 +39,12 @@ 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() 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..867555a6 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 @@ -27,9 +23,6 @@ import com.mikepenz.iconics.typeface.IIcon import com.pitchedapps.frost.BuildConfig import com.pitchedapps.frost.R import com.pitchedapps.frost.utils.Prefs -import org.jetbrains.anko.doAsync -import org.jetbrains.anko.uiThread -import java.security.InvalidParameterException /** 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..ff8ea050 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. @@ -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", 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..58c7b121 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt @@ -349,6 +349,7 @@ class MainActivity : BaseActivity(), SearchWebView.SearchContract, override fun searchOverlayDispose() { hiddenSearchView?.dispose() hiddenSearchView = null + searchView?.unBind { launchWebOverlay(FbTab.SEARCH.url); true } searchView = null } @@ -369,10 +370,11 @@ 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, _ -> + hiddenSearchView?.query(query) } + textDebounceInterval = 200L foregroundColor = Prefs.textColor backgroundColor = Prefs.bgColor.withMinAlpha(200) openListener = { hiddenSearchView?.pauseLoad = false } @@ -380,8 +382,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(FbTab.SEARCH.url); true } } return true } 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..200c5fa4 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/SettingsActivity.kt @@ -38,7 +38,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() + reload() } override fun kPrefCoreAttributes(): CoreAttributeContract.() -> Unit = { @@ -130,8 +130,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/settings/Experimental.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Experimental.kt index 594cbe01..307770d8 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 @@ -28,7 +30,13 @@ fun SettingsActivity.getExperimentalPrefs(): KPrefAdapterBuilder.() -> Unit = { // 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/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/iab/IABBinder.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/iab/IABBinder.kt index bad7f8fd..47331833 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,12 @@ import com.pitchedapps.frost.BuildConfig import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.frostAnswers +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,18 +39,22 @@ 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() { bp?.release() bp = null - activity = null + activityRef.clear() } override fun onBillingInitialized() = L.d("IAB initialized") @@ -52,12 +62,25 @@ abstract class IABBinder : FrostBilling { 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.d("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)) + } + }) + } } } @@ -107,15 +130,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 +168,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/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/FrostWebViewClients.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt index 94bff3c3..f3068c43 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt @@ -1,7 +1,6 @@ package com.pitchedapps.frost.web import android.content.Context -import android.content.Intent import android.graphics.Bitmap import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse @@ -98,7 +97,7 @@ open class FrostWebViewClient(val webCore: FrostWebViewCore) : BaseWebViewClient }) } - open fun handleHtml(html: String) { + open fun handleHtml(html: String?) { L.d("Handle Html") } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/MessageWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/MessageWebView.kt index 53fa0657..e79ab3b8 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/MessageWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/MessageWebView.kt @@ -54,7 +54,8 @@ class MessageWebView(val service: NotificationService, val params: JobParameters inner class MessageJSI { @JavascriptInterface - fun handleHtml(html: String) { + fun handleHtml(html: String?) { + html ?: return if (isCancelled) return if (html.length < 10) return finish() val time = System.currentTimeMillis() - startTime 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..fb4e5bf7 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/SearchWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/SearchWebView.kt @@ -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) } } @@ -60,13 +60,14 @@ class SearchWebView(context: Context, val contract: SearchContract) : WebView(co element -> //split text into separate items L.v("Search element ${element.attr("href")}") - val texts = element.select("div").map { (it.text()) }.filter { it.isNotBlank() } + val texts = element.select("div").map { it.text() }.filter { !it.isNullOrBlank() } val pair = Pair(texts, element.attr("href")) L.v("Search element potential $pair") 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) @@ -104,7 +105,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) diff --git a/app/src/main/res/xml/frost_changelog.xml b/app/src/main/res/xml/frost_changelog.xml index e7681107..ddaa6312 100644 --- a/app/src/main/res/xml/frost_changelog.xml +++ b/app/src/main/res/xml/frost_changelog.xml @@ -13,7 +13,7 @@ - + diff --git a/app/src/test/kotlin/com/pitchedapps/frost/MiscTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/MiscTest.kt index c9d27a1c..91e2149c 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/MiscTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/MiscTest.kt @@ -2,6 +2,7 @@ package com.pitchedapps.frost import com.pitchedapps.frost.injectors.CssHider import org.junit.Test +import kotlin.test.assertEquals /** * Created by Allan Wang on 2017-06-14. @@ -9,7 +10,12 @@ import org.junit.Test class MiscTest { @Test - fun asdf() { + fun headerFunction() { print(CssHider.HEADER.injector.function) } + + @Test + fun nullPair() { + assertEquals(Pair(null, 2), Pair(null, 2)) + } } \ No newline at end of file -- cgit v1.2.3