Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Native Android Swap (no webview) + Buy & Sell #20

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions .idea/gradle.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,19 @@
# tonkeeper
# Tonkeeper Contest

This project pertains to the Tonkeeper contest. In this project the Swap and Buy/Sell features are implemented.


### Swap:

I have developed and written the code to perform a native swap without using any webview. In this project, I have implemented swap natively only using Kotlin code (without using webview) and have not used any Javascript injection into a webview. This has resulted into a smooth swap experience and great performance. Also the maintainability and scalability of the project is much higher.
From the creation of the swap object bundle to signing it and sending it to the blockchain happens inside Kotlin environment.

### Buy/Sell:

I have also implemented Buy and sell feature where user can enter TON amount and select payment method and currency and then operator and finally is redirected to the operators web payment gateway. Then after successful Buy/Sell user is returned to the application.

### Important:
Because the tonkeeper android project is Work-In-Progress in order to be able to send token or swap token you should make sure that your wallet has been initialized.

### Test:
The changes made on this project have been tested on a wide variety of devices and all swap operations happen successfully.
7 changes: 4 additions & 3 deletions apps/signer/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ android {
compileSdk = Build.compileSdkVersion

defaultConfig {
applicationId = "com.tonapps.signer"
applicationId = Build.namespacePrefix("signer")
minSdk = Build.minSdkVersion
targetSdk = 34
versionCode = 8
versionName = "0.0.8"
versionCode = 11
versionName = "0.1.1"
}

lint {
Expand Down Expand Up @@ -68,6 +68,7 @@ dependencies {
implementation(Dependence.AndroidX.fragment)
implementation(Dependence.AndroidX.recyclerView)
implementation(Dependence.AndroidX.viewPager2)
implementation(Dependence.AndroidX.splashscreen)

implementation(Dependence.UI.material)
implementation(Dependence.AndroidX.Camera.base)
Expand Down
8 changes: 4 additions & 4 deletions apps/signer/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,18 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:supportsRtl="false"
android:theme="@style/Theme.Signer.Starting"
android:hardwareAccelerated="true"
android:largeHeap="true">
android:largeHeap="true"
android:localeConfig="@xml/locales_config">
<activity
android:name="com.tonapps.signer.screen.root.RootActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:hardwareAccelerated="true"
android:theme="@style/Theme.Signer.Starting"
android:taskAffinity=""
android:largeHeap="true"
android:screenOrientation="portrait"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode">
Expand All @@ -64,7 +64,7 @@
</intent-filter>
</activity>
<activity
android:theme="@style/Theme.Signer.Starting"
android:theme="@style/Theme.Signer"
android:name="com.tonapps.signer.screen.crash.CrashActivity"
android:process=":crash_activity"
android:exported="false"/>
Expand Down
Binary file modified apps/signer/src/main/ic_launcher-playstore.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/signer/src/main/java/com/tonapps/signer/Key.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ object Key {
const val RETURN = "return"
const val V = "v"
const val SCHEME = "tonsign"
const val SIGN = "sign"
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.ton.api.pub.PublicKeyEd25519
import org.ton.crypto.hex

class KeyRepository(
private val dataSource: KeyDataSource,
Expand All @@ -27,6 +28,12 @@ class KeyRepository(

init {
Scope.repositories.launch(Dispatchers.IO) {
/*val testKey = KeyEntity(
id = 1,
name = "Test",
publicKey = PublicKeyEd25519(hex("db642e022c80911fe61f19eb4f22d7fb95c1ea0b589c0f74ecf0cbf6db746c13"))
)
_keysEntityFlow.value = listOf(testKey)*/
_keysEntityFlow.value = dataSource.getEntities()
}
}
Expand All @@ -43,10 +50,13 @@ class KeyRepository(
_keysEntityFlow.value?.size ?: 0
}

fun findIdByPublicKey(publicKey: PublicKeyEd25519): Flow<Long> = flow {
val id = dataSource.findIdByPublicKey(publicKey)
emit(id)
}.filterNotNull()
fun findIdByPublicKey(publicKey: PublicKeyEd25519): Long? {
val id = dataSource.findIdByPublicKey(publicKey) ?: return null
if (0 >= id) {
return null
}
return id
}

suspend fun setName(id: Long, name: String) = withContext(Dispatchers.IO) {
dataSource.setName(id, name)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package com.tonapps.signer.deeplink
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import com.tonapps.blockchain.ton.extensions.base64
import com.tonapps.blockchain.ton.extensions.hex
import com.tonapps.security.hex
import com.tonapps.signer.Key
import org.ton.api.pub.PublicKeyEd25519

Expand All @@ -12,26 +15,34 @@ object TKDeepLink {
private const val STORE_LINK = "https://play.google.com/store/apps/details?id=com.tonapps.tonkeeperx"
private const val APP_SCHEME = "tonkeeper"

fun buildPublishUri(boc: String): Uri {
fun buildPublishUri(signature: ByteArray): Uri {
val baseUri = Uri.parse("${APP_SCHEME}://publish")
val builder = baseUri.buildUpon()
builder.appendQueryParameter(Key.BOC, boc)
builder.appendQueryParameter(Key.SIGN, hex(signature))
return builder.build()
}

fun buildLinkUri(publicKey: PublicKeyEd25519, name: String): Uri {
fun buildLinkUri(
publicKey: PublicKeyEd25519,
name: String,
local: Boolean
): Uri {
val baseUri = Uri.parse("${APP_SCHEME}://signer/link")
val builder = baseUri.buildUpon()
builder.appendQueryParameter("pk", publicKey.base64())
builder.appendQueryParameter("pk", publicKey.hex())
builder.appendQueryParameter("name", name)
if (local) {
builder.appendQueryParameter("local", "true")
}
return builder.build()
}

fun buildLinkUriWeb(publicKey: PublicKeyEd25519, name: String): Uri {
val baseUri = Uri.parse("https://wallet.tonkeeper.com/signer/link")
val builder = baseUri.buildUpon()
builder.appendQueryParameter("pk", publicKey.base64())
builder.appendQueryParameter("pk", publicKey.hex())
builder.appendQueryParameter("name", name)
builder.appendQueryParameter("local", "true")
return builder.build()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.tonapps.signer.deeplink.entities

import android.net.Uri
import android.util.Log
import com.tonapps.blockchain.ton.extensions.safeParseCell
import com.tonapps.blockchain.ton.extensions.safePublicKey
import com.tonapps.signer.Key
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,16 @@ object Password {
val dialog = PasswordDialog(context, ::trySend)
dialog.setOnDismissListener {
if (isActive) {
close(Throwable("User canceled"))
close(UserCancelThrowable )
} else {
close()
}
}
dialog.show()
awaitClose { dialog.destroy() }
}.flowOn(Dispatchers.Main).take(1)

object UserCancelThrowable : Throwable("User canceled") {
private fun readResolve(): Any = UserCancelThrowable
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class CameraFragment: BaseFragment(R.layout.fragment_camera), BaseFragment.Botto
}

private var isFlashAvailable = false
private var isReady = false

private val qrAnalyzer = QRImageAnalyzer()
private val chunks = mutableListOf<String>()
Expand Down Expand Up @@ -95,6 +96,9 @@ class CameraFragment: BaseFragment(R.layout.fragment_camera), BaseFragment.Botto
}

private fun handleBarcode(barcode: Barcode) {
if (isReady) {
return
}
val data = barcode.rawValue ?: return

if (data.startsWith("${Key.SCHEME}://")) {
Expand All @@ -108,12 +112,13 @@ class CameraFragment: BaseFragment(R.layout.fragment_camera), BaseFragment.Botto

val uri = chunks.joinToString("").uriOrNull ?: return
if (rootViewModel.processDeepLink(uri, false)) {
isReady = true
finishDelay()
}
}

private fun finishDelay() {
postDelayed(ModalView.animationDuration) {
postDelayed(300) {
finish()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.tonapps.signer.screen.create

import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.core.view.doOnLayout
import androidx.lifecycle.lifecycleScope
Expand Down Expand Up @@ -53,6 +54,7 @@ class CreateFragment: BaseFragment(R.layout.fragment_create), BaseFragment.Swipe
adapter = PagerAdapter(this, createViewModel.pages)

pagerView = view.findViewById(R.id.pager)
pagerView.offscreenPageLimit = adapter.itemCount
pagerView.isUserInputEnabled = false
pagerView.adapter = adapter

Expand All @@ -65,7 +67,7 @@ class CreateFragment: BaseFragment(R.layout.fragment_create), BaseFragment.Swipe
private fun setPage(index: Int) {
val currentIndex = pagerView.currentItem
if (currentIndex != index) {
pagerView.setCurrentItem(index, true)
post { pagerView.setCurrentItem(index, true) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ import kotlinx.coroutines.launch
import org.ton.api.pk.PrivateKeyEd25519
import org.ton.mnemonic.Mnemonic
import com.tonapps.security.tryCallGC
import com.tonapps.signer.extensions.authorizationRequiredError
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.withContext
import javax.crypto.SecretKey

class CreateViewModel(
Expand All @@ -54,15 +58,19 @@ class CreateViewModel(
add(PageType.Name)
}

private val _onReady = Channel<Unit>(Channel.BUFFERED)
val onReady: Flow<Unit> = _onReady.receiveAsFlow()
private val _onReady = MutableSharedFlow<Unit>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val onReady: Flow<Unit> = _onReady.asSharedFlow()

private val _currentPage = MutableStateFlow(pages.first())
private val _currentPage = MutableSharedFlow<PageType>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val currentPage = _currentPage.asSharedFlow()

private val _uiTopOffset = MutableStateFlow(0)
val uiTopOffset = _uiTopOffset.asStateFlow()

init {
_currentPage.tryEmit(pages.first())
}

fun pageIndex() = currentPage.map { pageIndex(it) }

fun page(pageType: PageType) = currentPage.filter { it == pageType }
Expand Down Expand Up @@ -98,22 +106,27 @@ class CreateViewModel(
return true
}

fun addKey(context: Context) {
viewModelScope.launch(Dispatchers.IO) {
try {
val name = args.name ?: throw IllegalStateException("Name is null")
val secret = masterSecret(context).single()
Password.setUnlock()

if (import) {
val mnemonic = args.mnemonic ?: throw IllegalStateException("Mnemonic is null")
addNewKey(secret, name, mnemonic)
} else {
createNewKey(secret, name)
}

_onReady.trySend(Unit)
} catch (e: Throwable) {
fun addKey(context: Context) = flow {
try {
val name = args.name ?: throw IllegalStateException("Name is null")
val secret = masterSecret(context).single()
Password.setUnlock()

if (import) {
val mnemonic = args.mnemonic ?: throw IllegalStateException("Mnemonic is null")
addNewKey(secret, name, mnemonic)
} else {
createNewKey(secret, name)
}

emit(true)
_onReady.tryEmit(Unit)

} catch (e: Throwable) {
emit(false)
if (e is Password.UserCancelThrowable) {
context.authorizationRequiredError()
} else {
CrashActivity.open(e, context)
_currentPage.tryEmit(pages.first())
}
Expand All @@ -131,14 +144,21 @@ class CreateViewModel(

private fun createMasterSecret(password: CharArray) = flow {
emit(vault.createMasterSecret(password))
}.take(1)
}.take(1).flowOn(Dispatchers.IO)

private suspend fun createNewKey(secret: SecretKey, name: String) {
private suspend fun createNewKey(
secret: SecretKey,
name: String
) = withContext(Dispatchers.IO) {
val mnemonic = Mnemonic.generate()
addNewKey(secret, name, mnemonic)
}

private suspend fun addNewKey(secret: SecretKey, name: String, mnemonic: List<String>) {
private suspend fun addNewKey(
secret: SecretKey,
name: String,
mnemonic: List<String>
) = withContext(Dispatchers.IO) {
val seed = Mnemonic.toSeed(mnemonic)
val publicKey = PrivateKeyEd25519(seed).publicKey()

Expand Down
Loading