From c350ac12e0e96529886f4c39d62f74889ee0ce6e Mon Sep 17 00:00:00 2001 From: Felix Ableitner Date: Fri, 24 Apr 2015 00:43:21 +0200 Subject: [PATCH] Allow sending bitcoins in chat. --- PROTOCOL.md | 26 ++ app/build.gradle | 1 + .../ensichat/protocol/RouterTest.scala | 7 +- .../protocol/body/ConnectionInfoTest.scala | 2 +- .../body/PaymentInformationTest.scala | 16 + .../protocol/header/ContentHeaderTest.scala | 19 +- .../nutomic/ensichat/util/DatabaseTest.scala | 33 +- .../android/BitcoinIntegration.java | 284 ++++++++++++++++++ app/src/main/res/drawable-hdpi/ic_bitcoin.png | Bin 0 -> 957 bytes app/src/main/res/drawable-mdpi/ic_bitcoin.png | Bin 0 -> 635 bytes .../main/res/drawable-xhdpi/ic_bitcoin.png | Bin 0 -> 1290 bytes .../main/res/drawable-xxhdpi/ic_bitcoin.png | Bin 0 -> 1750 bytes app/src/main/res/layout/fragment_chat.xml | 7 + app/src/main/res/values/settings_defaults.xml | 15 +- app/src/main/res/values/strings.xml | 13 + app/src/main/res/xml/settings.xml | 10 +- .../ensichat/bluetooth/TransferThread.scala | 2 +- .../ensichat/fragments/ChatFragment.scala | 91 ++++-- .../ensichat/fragments/SettingsFragment.scala | 7 + .../ensichat/protocol/ChatService.scala | 41 +-- .../nutomic/ensichat/protocol/Crypto.scala | 6 +- .../nutomic/ensichat/protocol/Router.scala | 4 +- .../protocol/body/InitiatePayment.scala | 34 +++ .../protocol/body/PaymentInformation.scala | 53 ++++ .../nutomic/ensichat/protocol/body/Text.scala | 2 +- .../protocol/header/ContentHeader.scala | 8 +- .../protocol/header/MessageHeader.scala | 4 +- .../com/nutomic/ensichat/util/Database.scala | 119 ++++++-- .../nutomic/ensichat/util/FutureHelper.scala | 4 +- .../ensichat/util/NotificationHandler.scala | 42 +-- .../nutomic/ensichat/views/DatesAdapter.scala | 2 +- .../ensichat/views/MessagesAdapter.scala | 2 +- 32 files changed, 736 insertions(+), 118 deletions(-) create mode 100644 app/src/androidTest/scala/com/nutomic/ensichat/protocol/body/PaymentInformationTest.scala create mode 100644 app/src/main/java/de/schildbach/wallet/integration/android/BitcoinIntegration.java create mode 100644 app/src/main/res/drawable-hdpi/ic_bitcoin.png create mode 100644 app/src/main/res/drawable-mdpi/ic_bitcoin.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_bitcoin.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_bitcoin.png create mode 100644 app/src/main/scala/com/nutomic/ensichat/protocol/body/InitiatePayment.scala create mode 100644 app/src/main/scala/com/nutomic/ensichat/protocol/body/PaymentInformation.scala diff --git a/PROTOCOL.md b/PROTOCOL.md index b7da17f..04b0152 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -265,3 +265,29 @@ Text the string to be transferred, encoded as UTF-8. Contains the sender's name and status, which should be used for display to users. + +### InitiatePayment (Type = 5) + +Requests PaymentRequest message. + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Reserved | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + +### PaymentInformation (Type = 6) + +Contains Bitcoin payment info. + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Payment Request Length | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + / / + \ Payment Request (variable length) \ + / / + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + +Payment Request is a protobuf-formatted Bitcoin payment message. diff --git a/app/build.gradle b/app/build.gradle index 81d761e..d0335c5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -18,6 +18,7 @@ dependencies { compile "org.scala-lang:scala-library:2.11.7" compile 'com.google.guava:guava:18.0' compile 'com.mobsandgeeks:adapter-kit:0.5.3' + // TODO: use @aar maven dependency for bitcoin } // RtlHardcoded behaviour differs between target API versions. We only care about API 15. diff --git a/app/src/androidTest/scala/com/nutomic/ensichat/protocol/RouterTest.scala b/app/src/androidTest/scala/com/nutomic/ensichat/protocol/RouterTest.scala index 298a778..8a4101d 100644 --- a/app/src/androidTest/scala/com/nutomic/ensichat/protocol/RouterTest.scala +++ b/app/src/androidTest/scala/com/nutomic/ensichat/protocol/RouterTest.scala @@ -90,15 +90,16 @@ class RouterTest extends AndroidTestCase { } def testHopLimit(): Unit = Range(19, 22).foreach { i => - val msg = new Message( - new ContentHeader(AddressTest.a1, AddressTest.a2, 1, 1, Some(1), Some(new Date()), i), new Text("")) + val header = + new ContentHeader(AddressTest.a1, AddressTest.a2, 1, 1, Some(1), Some(new Date()), false, i) + val msg = new Message(header, new Text("")) val router: Router = new Router(neighbors, (a, m) => fail()) router.onReceive(msg) } private def generateMessage(sender: Address, receiver: Address, seqNum: Int): Message = { val header = new ContentHeader(sender, receiver, seqNum, UserInfo.Type, Some(5), - Some(new GregorianCalendar(2014, 6, 10).getTime)) + Some(new GregorianCalendar(2014, 6, 10).getTime), false) new Message(header, new UserInfo("", "")) } diff --git a/app/src/androidTest/scala/com/nutomic/ensichat/protocol/body/ConnectionInfoTest.scala b/app/src/androidTest/scala/com/nutomic/ensichat/protocol/body/ConnectionInfoTest.scala index 1cf5550..79bc67f 100644 --- a/app/src/androidTest/scala/com/nutomic/ensichat/protocol/body/ConnectionInfoTest.scala +++ b/app/src/androidTest/scala/com/nutomic/ensichat/protocol/body/ConnectionInfoTest.scala @@ -22,7 +22,7 @@ class ConnectionInfoTest extends AndroidTestCase { val ci = ConnectionInfoTest.generateCi(getContext) val bytes = ci.write val body = ConnectionInfo.read(bytes) - Assert.assertEquals(ci.key, body.asInstanceOf[ConnectionInfo].key) + Assert.assertEquals(ci, body) } } diff --git a/app/src/androidTest/scala/com/nutomic/ensichat/protocol/body/PaymentInformationTest.scala b/app/src/androidTest/scala/com/nutomic/ensichat/protocol/body/PaymentInformationTest.scala new file mode 100644 index 0000000..e7026c3 --- /dev/null +++ b/app/src/androidTest/scala/com/nutomic/ensichat/protocol/body/PaymentInformationTest.scala @@ -0,0 +1,16 @@ +package com.nutomic.ensichat.protocol.body + +import android.test.AndroidTestCase +import junit.framework.Assert + + +class PaymentInformationTest extends AndroidTestCase { + + def testWriteRead(): Unit = { + val pi = new PaymentInformation("testmessage".getBytes) + val bytes = pi.write + val body = PaymentInformation.read(bytes) + Assert.assertEquals(pi, body) + } + +} diff --git a/app/src/androidTest/scala/com/nutomic/ensichat/protocol/header/ContentHeaderTest.scala b/app/src/androidTest/scala/com/nutomic/ensichat/protocol/header/ContentHeaderTest.scala index e416ab8..574f2df 100644 --- a/app/src/androidTest/scala/com/nutomic/ensichat/protocol/header/ContentHeaderTest.scala +++ b/app/src/androidTest/scala/com/nutomic/ensichat/protocol/header/ContentHeaderTest.scala @@ -1,30 +1,33 @@ package com.nutomic.ensichat.protocol.header -import java.util.{GregorianCalendar, Date} +import java.util.{Date, GregorianCalendar} import android.test.AndroidTestCase -import com.nutomic.ensichat.protocol.body.Text +import com.nutomic.ensichat.protocol.body.{PaymentInformation, Text} import com.nutomic.ensichat.protocol.{Address, AddressTest} import junit.framework.Assert._ object ContentHeaderTest { val h1 = new ContentHeader(AddressTest.a1, AddressTest.a2, 1234, - Text.Type, Some(123), Some(new GregorianCalendar(1970, 1, 1).getTime), 5) + Text.Type, Some(123), Some(new GregorianCalendar(1970, 1, 1).getTime), false, 5) val h2 = new ContentHeader(AddressTest.a1, AddressTest.a3, - 30000, Text.Type, Some(8765), Some(new GregorianCalendar(2014, 6, 10).getTime), 20) + 30000, Text.Type, Some(8765), Some(new GregorianCalendar(2014, 6, 10).getTime), false, 20) val h3 = new ContentHeader(AddressTest.a4, AddressTest.a2, - 250, Text.Type, Some(77), Some(new GregorianCalendar(2020, 11, 11).getTime), 123) + 250, Text.Type, Some(77), Some(new GregorianCalendar(2020, 11, 11).getTime), false, 123) val h4 = new ContentHeader(Address.Null, Address.Broadcast, - ContentHeader.SeqNumRange.last, 0, Some(0xffff), Some(new Date(0L)), 0xff) + ContentHeader.SeqNumRange.last, 0, Some(0xffff), Some(new Date(0L)), false, 0xff) val h5 = new ContentHeader(Address.Broadcast, Address.Null, - 0, 0xff, Some(0), Some(new Date(0xffffffffL)), 0) + 0, 0xff, Some(0), Some(new Date(0xffffffffL)), false, 0) - val headers = Set(h1, h2, h3, h4, h5) + val h6 = new ContentHeader(AddressTest.a1, AddressTest.a2, 1234, + PaymentInformation.Type, Some(123), Some(new GregorianCalendar(2015, 8, 9).getTime), false, 5) + + val headers = Set(h1, h2, h3, h4, h5, h6) } diff --git a/app/src/androidTest/scala/com/nutomic/ensichat/util/DatabaseTest.scala b/app/src/androidTest/scala/com/nutomic/ensichat/util/DatabaseTest.scala index 6c5141f..455e8a6 100644 --- a/app/src/androidTest/scala/com/nutomic/ensichat/util/DatabaseTest.scala +++ b/app/src/androidTest/scala/com/nutomic/ensichat/util/DatabaseTest.scala @@ -8,10 +8,10 @@ import android.database.sqlite.SQLiteDatabase import android.support.v4.content.LocalBroadcastManager import android.test.AndroidTestCase import com.nutomic.ensichat.protocol.MessageTest._ -import com.nutomic.ensichat.protocol.body.CryptoData -import com.nutomic.ensichat.protocol.header.{ContentHeader, ContentHeaderTest} +import com.nutomic.ensichat.protocol.body.{CryptoData, PaymentInformation} +import com.nutomic.ensichat.protocol.header.ContentHeader import com.nutomic.ensichat.protocol.header.ContentHeaderTest._ -import com.nutomic.ensichat.protocol.{MessageTest, UserTest} +import com.nutomic.ensichat.protocol.{AddressTest, Message, MessageTest, UserTest} import junit.framework.Assert._ object DatabaseTest { @@ -68,18 +68,33 @@ class DatabaseTest extends AndroidTestCase { assertTrue(msg.contains(m3)) } - def testMessageFields(): Unit = { - val msg = database.getMessages(m2.header.target, 1).firstKey + def testTextMessage(): Unit = { + val msg = database.getMessages(m3.header.target, 1).firstKey val header = msg.header.asInstanceOf[ContentHeader] assertEquals(h2.origin, header.origin) assertEquals(h2.target, header.target) assertEquals(-1, msg.header.seqNum) - assertEquals(h2.contentType, header.contentType) - assertEquals(h2.messageId, header.messageId) - assertEquals(h2.time, header.time) + assertEquals(h3.contentType, header.contentType) + assertEquals(h3.messageId, header.messageId) + assertEquals(h3.time, header.time) + assertEquals(h3.read, header.read) assertEquals(new CryptoData(None, None), msg.crypto) - assertEquals(m2.body, msg.body) + assertEquals(m3.body, msg.body) + } + + def testPaymentRequestMessage(): Unit = { + val pr = new PaymentInformation("teststring".getBytes) + val msg = new Message(h6, pr) + database.onMessageReceived(msg) + val retrieved = database.getMessages(h6.origin, 1).firstKey + assertEquals(pr, retrieved.body) + } + + def testMessageRead(): Unit = { + database.setMessageRead(h3) + val header = database.getMessages(AddressTest.a4, 1).firstKey.header.asInstanceOf[ContentHeader] + assertTrue(header.read) } def testAddContact(): Unit = { diff --git a/app/src/main/java/de/schildbach/wallet/integration/android/BitcoinIntegration.java b/app/src/main/java/de/schildbach/wallet/integration/android/BitcoinIntegration.java new file mode 100644 index 0000000..496defa --- /dev/null +++ b/app/src/main/java/de/schildbach/wallet/integration/android/BitcoinIntegration.java @@ -0,0 +1,284 @@ +/** + * Copyright 2012-2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.schildbach.wallet.integration.android; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.widget.Toast; + +/** + * @author Andreas Schildbach + */ +public final class BitcoinIntegration +{ + private static final String INTENT_EXTRA_PAYMENTREQUEST = "paymentrequest"; + private static final String INTENT_EXTRA_PAYMENT = "payment"; + private static final String INTENT_EXTRA_TRANSACTION_HASH = "transaction_hash"; + + private static final String MIMETYPE_PAYMENTREQUEST = "application/bitcoin-paymentrequest"; // BIP 71 + + /** + * Request any amount of Bitcoins (probably a donation) from user, without feedback from the app. + * + * @param context + * Android context + * @param address + * Bitcoin address + */ + public static void request(final Context context, final String address) + { + final Intent intent = makeBitcoinUriIntent(address, null); + + start(context, intent); + } + + /** + * Request specific amount of Bitcoins from user, without feedback from the app. + * + * @param context + * Android context + * @param address + * Bitcoin address + * @param amount + * Bitcoin amount in satoshis + */ + public static void request(final Context context, final String address, final long amount) + { + final Intent intent = makeBitcoinUriIntent(address, amount); + + start(context, intent); + } + + /** + * Request payment from user, without feedback from the app. + * + * @param context + * Android context + * @param paymentRequest + * BIP70 formatted payment request + */ + public static void request(final Context context, final byte[] paymentRequest) + { + final Intent intent = makePaymentRequestIntent(paymentRequest); + + start(context, intent); + } + + /** + * Request any amount of Bitcoins (probably a donation) from user, with feedback from the app. Result intent can be + * received by overriding {@link android.app.Activity#onActivityResult()}. Result indicates either + * {@link Activity#RESULT_OK} or {@link Activity#RESULT_CANCELED}. In the success case, use + * {@link #transactionHashFromResult(Intent)} to read the transaction hash from the intent. + * + * Warning: A success indication is no guarantee! To be on the safe side, you must drive your own Bitcoin + * infrastructure and validate the transaction. + * + * @param activity + * Calling Android activity + * @param requestCode + * Code identifying the call when {@link android.app.Activity#onActivityResult()} is called back + * @param address + * Bitcoin address + */ + public static void requestForResult(final Activity activity, final int requestCode, final String address) + { + final Intent intent = makeBitcoinUriIntent(address, null); + + startForResult(activity, requestCode, intent); + } + + /** + * Request specific amount of Bitcoins from user, with feedback from the app. Result intent can be received by + * overriding {@link android.app.Activity#onActivityResult()}. Result indicates either {@link Activity#RESULT_OK} or + * {@link Activity#RESULT_CANCELED}. In the success case, use {@link #transactionHashFromResult(Intent)} to read the + * transaction hash from the intent. + * + * Warning: A success indication is no guarantee! To be on the safe side, you must drive your own Bitcoin + * infrastructure and validate the transaction. + * + * @param activity + * Calling Android activity + * @param requestCode + * Code identifying the call when {@link android.app.Activity#onActivityResult()} is called back + * @param address + * Bitcoin address + */ + public static void requestForResult(final Activity activity, final int requestCode, final String address, final long amount) + { + final Intent intent = makeBitcoinUriIntent(address, amount); + + startForResult(activity, requestCode, intent); + } + + /** + * Request payment from user, with feedback from the app. Result intent can be received by overriding + * {@link android.app.Activity#onActivityResult()}. Result indicates either {@link Activity#RESULT_OK} or + * {@link Activity#RESULT_CANCELED}. In the success case, use {@link #transactionHashFromResult(Intent)} to read the + * transaction hash from the intent. + * + * Warning: A success indication is no guarantee! To be on the safe side, you must drive your own Bitcoin + * infrastructure and validate the transaction. + * + * @param activity + * Calling Android activity + * @param requestCode + * Code identifying the call when {@link android.app.Activity#onActivityResult()} is called back + * @param paymentRequest + * BIP70 formatted payment request + */ + public static void requestForResult(final Activity activity, final int requestCode, final byte[] paymentRequest) + { + final Intent intent = makePaymentRequestIntent(paymentRequest); + + startForResult(activity, requestCode, intent); + } + + /** + * Get payment request from intent. Meant for usage by applications accepting payment requests. + * + * @param intent + * intent + * @return payment request or null + */ + public static byte[] paymentRequestFromIntent(final Intent intent) + { + final byte[] paymentRequest = intent.getByteArrayExtra(INTENT_EXTRA_PAYMENTREQUEST); + + return paymentRequest; + } + + /** + * Put BIP70 payment message into result intent. Meant for usage by Bitcoin wallet applications. + * + * @param result + * result intent + * @param payment + * payment message + */ + public static void paymentToResult(final Intent result, final byte[] payment) + { + result.putExtra(INTENT_EXTRA_PAYMENT, payment); + } + + /** + * Get BIP70 payment message from result intent. Meant for usage by applications initiating a Bitcoin payment. + * + * You can use the transactions contained in the payment to validate the payment. For this, you need your own + * Bitcoin infrastructure though. There is no guarantee that the payment will ever confirm. + * + * @param result + * result intent + * @return payment message + */ + public static byte[] paymentFromResult(final Intent result) + { + final byte[] payment = result.getByteArrayExtra(INTENT_EXTRA_PAYMENT); + + return payment; + } + + /** + * Put transaction hash into result intent. Meant for usage by Bitcoin wallet applications. + * + * @param result + * result intent + * @param txHash + * transaction hash + */ + public static void transactionHashToResult(final Intent result, final String txHash) + { + result.putExtra(INTENT_EXTRA_TRANSACTION_HASH, txHash); + } + + /** + * Get transaction hash from result intent. Meant for usage by applications initiating a Bitcoin payment. + * + * You can use this hash to request the transaction from the Bitcoin network, in order to validate. For this, you + * need your own Bitcoin infrastructure though. There is no guarantee that the transaction has ever been broadcasted + * to the Bitcoin network. + * + * @param result + * result intent + * @return transaction hash + */ + public static String transactionHashFromResult(final Intent result) + { + final String txHash = result.getStringExtra(INTENT_EXTRA_TRANSACTION_HASH); + + return txHash; + } + + private static final int SATOSHIS_PER_COIN = 100000000; + + private static Intent makeBitcoinUriIntent(final String address, final Long amount) + { + final StringBuilder uri = new StringBuilder("bitcoin:"); + if (address != null) + uri.append(address); + if (amount != null) + uri.append("?amount=").append(String.format("%d.%08d", amount / SATOSHIS_PER_COIN, amount % SATOSHIS_PER_COIN)); + + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri.toString())); + + return intent; + } + + private static Intent makePaymentRequestIntent(final byte[] paymentRequest) + { + final Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setType(MIMETYPE_PAYMENTREQUEST); + intent.putExtra(INTENT_EXTRA_PAYMENTREQUEST, paymentRequest); + + return intent; + } + + private static void start(final Context context, final Intent intent) + { + final PackageManager pm = context.getPackageManager(); + if (pm.resolveActivity(intent, 0) != null) + context.startActivity(intent); + else + redirectToDownload(context); + } + + private static void startForResult(final Activity activity, final int requestCode, final Intent intent) + { + final PackageManager pm = activity.getPackageManager(); + if (pm.resolveActivity(intent, 0) != null) + activity.startActivityForResult(intent, requestCode); + else + redirectToDownload(activity); + } + + private static void redirectToDownload(final Context context) + { + Toast.makeText(context, "No Bitcoin application found.\nPlease install Bitcoin Wallet.", Toast.LENGTH_LONG).show(); + + final Intent marketIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=de.schildbach.wallet")); + final Intent binaryIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/schildbach/bitcoin-wallet/releases")); + + final PackageManager pm = context.getPackageManager(); + if (pm.resolveActivity(marketIntent, 0) != null) + context.startActivity(marketIntent); + else if (pm.resolveActivity(binaryIntent, 0) != null) + context.startActivity(binaryIntent); + // else out of luck + } +} diff --git a/app/src/main/res/drawable-hdpi/ic_bitcoin.png b/app/src/main/res/drawable-hdpi/ic_bitcoin.png new file mode 100644 index 0000000000000000000000000000000000000000..d2d859d6edbf878e5bfd144bb6b108bf75b46b32 GIT binary patch literal 957 zcmV;u148_XP)1lQz2MV(|e%+gbJ&b>WMLY4~ggFrw~TQ-UK0l}aTF+Rx3CXz7; z5nU1E*UtW1AMAl*w!3FG$#jFvfqJ=J)tpm(s_yM8vcLihEbzaDs&0_Hud1Jt>^IHl z&;am(s_v`mKFPb|c$%hXoO9=rBv})YR)Z9)LGt^t0Pp=)6u&W!1Nfpbf+2)uGjp9z zXJztPzu$j>1Jb(69V*&tNiOED`MDq8DEt9S(;_y!V$> zbyOnaYV86_c6C&>o4K`$MDNzgU7T~PMg9hGuWDVIroA#N)x}*zL{v4M=KmtZNp0c_ zJg$qoh=>5VULxRfmSsytYdW3I3dsj00=}tLKpBzTM!%7~MKUw9x4rk5y!TrqcPrMg zHuBE7)h9__6-n)y)cKE4=Kp->XU)tb0QbynvsUsVB4+kUiGZ`6PG==ga387auwvKu zNM_@TwNA8({7>?6yWKuIITt{ixtOs`_Esc`&n+b0O@UJ56%E-EJSvvTR9Jzb*0f`V0a#D$c}#`st|=LRijw zJo^CtRMlU~vS@+-55Rjrs5lG$nF}y8oAFA#lO)NBs&|n*UR6@h#X@n9XNF|-H_2^P zy_%ns4KsVQ*XwmjzFU>KN&X=7$OYc}*Htx;yg~A(S;aiT2+y2+0a_$a0k|;b@9MB) zr}qD10Jo#hzYXAKRiRd4o%?yOv_6Coj*)G$=!I~1o20z`aa3y zwUQSR>GgWuh|I`)|FVckzu$i)CJEqd2w^!%lC`{?SWnaROg_K4?sVKfSJmM-uBzt` zn->~ET$lm4K8^$UJQ8rLNsiOdGP9EaE~IJt#yA$Y83DZ4D8s`d@Bg1C`5AzRjkeDh fSYUw#=8k^>w~l+IX2qPI00000NkvXXu0mjfkX6_j literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_bitcoin.png b/app/src/main/res/drawable-mdpi/ic_bitcoin.png new file mode 100644 index 0000000000000000000000000000000000000000..e038697efea07ef6590901078ad4c0117aa6a1d7 GIT binary patch literal 635 zcmV->0)+jEP)Y*96BA`;yXtjkS5|4yk|@Gn6%TqaIYeU;jVv_iK_Q~XY~uV| z56-~Q?8Fe893%yOnC|IU)gP~3i=5yD{{U58A^FA_GcO_%LTDTtx%Yk&z^qjacO>5zeOn=fhPBofGTAZj~#H8_BOEKNsK2Ny)2dwOW_Ub3dT{BDtxm zPDI9%3Ltrk!)h{E&EPL-KQwq3MB>=Edj`jQfaR42X-y;!ElL@faE=IwZ zM*MR>x*t2q1dv>-1|OC0BoPT1CLu43=V$e@sj4@|OnL7oL`1B$7gTjC60nv;KxsTR zX*e8l_BqKNk~fkF0PrLV{+8q>$zhGK*IAZbN;7TQHJc2 z>?I+eot-^Xb-^0D9aGh%XqWbnTo*zJ4d>i7@BKu(-M$;G?&YXC{eFMEYE*TpdMK3X zMDhp8y(p-!nR71w*IK)HQ~{d@`27cxk4eH9Gfna$fYzuv0OpP)z!)rUg@9r!l1D0N4;L}d=hdI z5$D`Y#qpZev~0KAHThKoI2M!_s<`oM0A3s$`3e48{03>m VG%nw{V_X0L002ovPDHLkV1k|_8F>Hz literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_bitcoin.png b/app/src/main/res/drawable-xhdpi/ic_bitcoin.png new file mode 100644 index 0000000000000000000000000000000000000000..bcb8078b14039755decd1f15c0731d15c98f38ea GIT binary patch literal 1290 zcmV+l1@-!gP)<}AR_0LQlpOJ427T3TJN^T{u+Qv%}%n9PigBnK}uGYY$x4{h;PUYkl16{*OrZrKu|s zBnL=7X(9n@!IJ0$xB%c!D*;#1)|QB3vAE53-4cK^0RAAk(quC1`C-!c{ob_oH4Mp* zNY1q)2_oW)$Tv!<9ck-ohok&&NuJm=Ux`}ly)lArlbj*B++ZT+wbpyn))x!O zi5MXhxm+%n&*%F|9t3cPV%N?K)oOJM z$$uh(zZW&Td!Dz|81rL--f*^)x)KvN{sCNw^Wpn`uZUca=>>N>p{HRIjQ_DDew0$9 zF+E_l-CCMLM1G12I@mSFVvJF7!=T+-nxO~4-H4$5q>k!rp6vFZjXIhm_zHX;5%l>a zejLXcike7+p>(y~dK$(UGaeK4i(r{MOA_UBc~`Ys-BNp>LGTWejk z)<JK^V*QaebF0hmr$M7d9LI4ymV zc%HYFwOLYQ z05}#EwANaZ`VtW>ubv;;Nx0ljJq<(hTGZ?vKL1PTFR~^;@<~3Q?{Al$4Ji}~gSA6T z$8p{Yg8dZ0(}-Z_vJQhjLM~M%*|MyM&&W1fIeriOr+^G1Vod@8M9EkgkLZL87^7lBO0H!;kc^xEYn@m7> z$jk)^`I=;zhyjjNRe3R45b%JDz|rLFIP8*7^v5 zOC%RNl>m|_QWakh-}ieHma<=0{EefGRAxfU^2KQaVeNn zrU0CDUAMGZZi!W`e{CTbx-kaeuw{RxKCr0~kz3Y3ysrEW;G&g)n`y<`a)Qdvxp za){)gByVc1k1C~h*J`zay!*9o&{~g)$Ult*z%!Cpl~TL&?qA;{A~Vqd)Qj)?Uq4CspEOr(9^`}f4#&x)OFpRN%ghXr}D6&r_ov;HPL?n(@FKq<+7894TXT@ zK9W~OBv4B2Zgr9ba6Li)U&ZM^$)6MIznPqz+?2g#Jp#!y5r?cfNd`eMn56#zu0`wD zYPA89j}roLyE~Q^6q0jE(omly0sLAYAtZ+;CMNbI)hBr<+J@Et1RTfNLh{`j0r;Q9 zh)6BrZ!08sMBB0Yp8(0PO`P9Ca25gVcU^aLq#djOafrzGSq7kC9(Ygkj)Ne z)&CesUIwt#i6FF6*0uiE@jP!tM3jh}BYB_XN*9B0!loi3xURc1=^ejAa`lYlnM$Ry zJ#X6!1IZx(zjsrP0K5k-}kqAp7#ZS!vJOhJj-A-tcb{ob-B#hLW-i%QFGD%uU4xY!j$!C z01Iu-56ANGL2j6)C`g`Z_IuT8bprrbME*!Q5%9X z!NU0X_{L7#UI;|ww2A&Zjn7*6#bU9 zt%W1h|F>D`e=`8g8jgV1S=w4iWHm38x&FtI=3_5QTMG$*3!T*eMtt8d+WZ}sT3<1IB2Q|qcbVpDlv1Nr!y|da>|63RxizFY3$O8ZiX`J3zJkL9jw~snXUJEcSKKwzEi2@-fMM-S$In znCO42`#DK|Dyuu+j90%J=76bA+nzfq^xtTKd~$Mf6Umb%YYYv+ULjfOlbD=QQ|F#{Wwf>CcqG14* z`*QPciu_NIMY|-crlUY>{Y@5a7m8+Qx8h1xA4jJ9PQ%InVis+8l`!ug!_A5 znHt(@{V$bP&!(kl|6Eq>cLe~4Gjx7t`tQ2#HW7K9q7S{ZEinj!!7S6u0G0quyRN&f zoqlbQv9GJV@AS|Pctlp4+3 zr>SuqXV~}sFB<}q<(pAA0?=Q1Ad*Aj*{vCpH;Nn(l5gW~U+F6x#~B82Hg9Jql$pH! z(-o3Z(j-@Myv*aLo_h0Xhoo-?l5?KtjfAhYmq^Zc`ZA9&=oi+_B_7RE(sA3*%jNRt zrf0hhoIl^rj_aPJ*^*?OGs@+%)5L*u;gvTnum1x0JA4_yx_RmwNz(tM<3|8~NT|Q$ zdEQ8=R4R>+k8e~;jfTNGNb*~)^{ILR@Ew2&l6!+7_^A6<7l>y1pHTm9LjAkl?t9N; zs{g+4Z*6jd=6oJD^fadWzebWz + + Let\'s chat! + true + 15 - true - 1000000 - - \ No newline at end of file + + + de.schildbach.wallet + de.schildbach.wallet_test + + + de.schildbach.wallet + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 905b9dc..934807a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -52,6 +52,11 @@ Contact added + + + Bitcoin wallet not found. Please install wallet app or select another wallet in the app settings. + + @@ -72,6 +77,14 @@ Notification Sounds + Bitcoin Wallet Application + + + + Bitcoin Wallet + Bitcoin Wallet for Testnet + + Maximum Number of Connections diff --git a/app/src/main/res/xml/settings.xml b/app/src/main/res/xml/settings.xml index bdb49c5..04996e5 100644 --- a/app/src/main/res/xml/settings.xml +++ b/app/src/main/res/xml/settings.xml @@ -22,6 +22,13 @@ android:inputType="number" android:numeric="integer" /> + + + android:key="version" + style="?android:preferenceInformationStyle" /> diff --git a/app/src/main/scala/com/nutomic/ensichat/bluetooth/TransferThread.scala b/app/src/main/scala/com/nutomic/ensichat/bluetooth/TransferThread.scala index ba409fb..c54be7e 100644 --- a/app/src/main/scala/com/nutomic/ensichat/bluetooth/TransferThread.scala +++ b/app/src/main/scala/com/nutomic/ensichat/bluetooth/TransferThread.scala @@ -5,10 +5,10 @@ import java.io._ import android.bluetooth.{BluetoothDevice, BluetoothSocket} import android.content.{IntentFilter, Intent, Context, BroadcastReceiver} import android.util.Log +import com.nutomic.ensichat.protocol.Message.ReadMessageException import com.nutomic.ensichat.protocol._ import com.nutomic.ensichat.protocol.body.ConnectionInfo import com.nutomic.ensichat.protocol.header.MessageHeader -import Message.ReadMessageException /** * Transfers data between connnected devices. diff --git a/app/src/main/scala/com/nutomic/ensichat/fragments/ChatFragment.scala b/app/src/main/scala/com/nutomic/ensichat/fragments/ChatFragment.scala index 38f3c52..498cbd7 100644 --- a/app/src/main/scala/com/nutomic/ensichat/fragments/ChatFragment.scala +++ b/app/src/main/scala/com/nutomic/ensichat/fragments/ChatFragment.scala @@ -1,8 +1,9 @@ package com.nutomic.ensichat.fragments -import android.app.ListFragment -import android.content.{BroadcastReceiver, Context, Intent, IntentFilter} +import android.app.{Activity, ListFragment} +import android.content.{ActivityNotFoundException, BroadcastReceiver, Context, Intent, IntentFilter} import android.os.Bundle +import android.preference.PreferenceManager import android.support.v4.content.LocalBroadcastManager import android.view.View.OnClickListener import android.view.inputmethod.EditorInfo @@ -11,15 +12,19 @@ import android.widget.TextView.OnEditorActionListener import android.widget._ import com.nutomic.ensichat.R import com.nutomic.ensichat.activities.EnsichatActivity -import com.nutomic.ensichat.protocol.body.Text +import com.nutomic.ensichat.protocol.body.{InitiatePayment, PaymentInformation, Text} +import com.nutomic.ensichat.protocol.header.ContentHeader import com.nutomic.ensichat.protocol.{Address, ChatService, Message} import com.nutomic.ensichat.util.Database import com.nutomic.ensichat.views.{DatesAdapter, MessagesAdapter} +import de.schildbach.wallet.integration.android.BitcoinIntegration /** * Represents a single chat with another specific device. */ -class ChatFragment extends ListFragment with OnClickListener { +class ChatFragment extends ListFragment with OnClickListener with OnEditorActionListener { + + private val REQUEST_FETCH_PAYMENT_REQUEST = 1 /** * Fragments need to have a default constructor, so this is optional. @@ -35,6 +40,8 @@ class ChatFragment extends ListFragment with OnClickListener { private var chatService: ChatService = _ + private var sendBitcoinButton: ImageButton = _ + private var sendButton: Button = _ private var messageText: EditText = _ @@ -55,6 +62,8 @@ class ChatFragment extends ListFragment with OnClickListener { adapter = new DatesAdapter(getActivity, new MessagesAdapter(getActivity, database.getMessagesCursor(address, None), address)) + // TODO: mark messages read + if (listView != null) { listView.setAdapter(adapter) } @@ -63,21 +72,17 @@ class ChatFragment extends ListFragment with OnClickListener { override def onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle): View = { - val view: View = inflater.inflate(R.layout.fragment_chat, container, false) - sendButton = view.findViewById(R.id.send).asInstanceOf[Button] + val view = inflater.inflate(R.layout.fragment_chat, container, false) + sendBitcoinButton = view.findViewById(R.id.send_bitcoin).asInstanceOf[ImageButton] + sendButton = view.findViewById(R.id.send).asInstanceOf[Button] + messageText = view.findViewById(R.id.message).asInstanceOf[EditText] + listView = view.findViewById(android.R.id.list).asInstanceOf[ListView] + + sendBitcoinButton.setOnClickListener(this) sendButton.setOnClickListener(this) - messageText = view.findViewById(R.id.message).asInstanceOf[EditText] - messageText.setOnEditorActionListener(new OnEditorActionListener { - override def onEditorAction(view: TextView, actionId: Int, event: KeyEvent): Boolean = { - if (actionId == EditorInfo.IME_ACTION_DONE) { - onClick(sendButton) - true - } else - false - } - }) - listView = view.findViewById(android.R.id.list).asInstanceOf[ListView] + messageText.setOnEditorActionListener(this) listView.setAdapter(adapter) + view } @@ -101,14 +106,24 @@ class ChatFragment extends ListFragment with OnClickListener { LocalBroadcastManager.getInstance(getActivity).unregisterReceiver(onMessageReceivedReceiver) } + override def onEditorAction(view: TextView, actionId: Int, event: KeyEvent): Boolean = { + if (actionId == EditorInfo.IME_ACTION_DONE) { + onClick(sendButton) + true + } else + false + } + /** * Send message if send button was clicked. */ override def onClick(view: View): Unit = view.getId match { + case R.id.send_bitcoin => + chatService.sendTo(address, new InitiatePayment()) case R.id.send => val text = messageText.getText.toString.trim if (!text.isEmpty) { - val message = new Text(text.toString) + val message = new Text(text) chatService.sendTo(address, message) messageText.getText.clear() } @@ -123,12 +138,48 @@ class ChatFragment extends ListFragment with OnClickListener { if (!Set(msg.header.origin, msg.header.target).contains(address)) return + val types: Set[Class[_]] = + Set(classOf[Text], classOf[InitiatePayment], classOf[PaymentInformation]) + if (!types.contains(msg.body.getClass)) + return + + val header = msg.header.asInstanceOf[ContentHeader] + if (msg.header.origin != address || header.read) + return + + database.setMessageRead(header) + adapter.changeCursor(database.getMessagesCursor(address, None)) + + // Special handling for Bitcoin messages. + // TODO: is this stuff working from background? msg.body match { case _: Text => - adapter.changeCursor(database.getMessagesCursor(address, None)) - case _ => + case _: InitiatePayment => + val pm = PreferenceManager.getDefaultSharedPreferences(getActivity) + + val wallet = pm.getString(SettingsFragment.KeyBitcoinWallet, + getString(R.string.default_bitcoin_wallet)) + val intent = new Intent() + intent.setClassName(wallet, "de.schildbach.wallet.ui.FetchPaymentRequestActivity") + intent.putExtra("sender_name", chatService.getUser(msg.header.origin).name) + try { + startActivityForResult(intent, REQUEST_FETCH_PAYMENT_REQUEST) + } catch { + case e: ActivityNotFoundException => + Toast.makeText(getActivity, R.string.bitcoin_wallet_not_found, Toast.LENGTH_LONG).show(); + } + case pr: PaymentInformation => + BitcoinIntegration.request(getActivity, pr.bytes) } } } + override def onActivityResult(requestCode: Int, resultCode: Int, data: Intent): Unit = requestCode match { + case REQUEST_FETCH_PAYMENT_REQUEST => + if (resultCode == Activity.RESULT_OK) { + val pr = new PaymentInformation(data.getByteArrayExtra("payment_request")) + chatService.sendTo(address, pr) + } + } + } diff --git a/app/src/main/scala/com/nutomic/ensichat/fragments/SettingsFragment.scala b/app/src/main/scala/com/nutomic/ensichat/fragments/SettingsFragment.scala index 112c696..c7fe4db 100644 --- a/app/src/main/scala/com/nutomic/ensichat/fragments/SettingsFragment.scala +++ b/app/src/main/scala/com/nutomic/ensichat/fragments/SettingsFragment.scala @@ -16,6 +16,7 @@ object SettingsFragment { val KeyUserName = "user_name" val KeyUserStatus = "user_status" val KeyScanInterval = "scan_interval_seconds" + val KeyBitcoinWallet = "bitcoin_wallet" val MaxConnections = "max_connections" val Version = "version" @@ -31,6 +32,7 @@ class SettingsFragment extends PreferenceFragment with OnPreferenceChangeListene private lazy val name = findPreference(KeyUserName) private lazy val status = findPreference(KeyUserStatus) private lazy val scanInterval = findPreference(KeyScanInterval) + private lazy val bitcoinWallet = findPreference(KeyBitcoinWallet) private lazy val maxConnections = findPreference(MaxConnections) private lazy val version = findPreference(Version) @@ -50,6 +52,10 @@ class SettingsFragment extends PreferenceFragment with OnPreferenceChangeListene scanInterval.setSummary(prefs.getString( KeyScanInterval, getResources.getString(R.string.default_scan_interval))) + bitcoinWallet.setOnPreferenceChangeListener(this) + bitcoinWallet.setSummary(prefs.getString(KeyBitcoinWallet, + getResources.getString(R.string.default_bitcoin_wallet))) + if (BuildConfig.DEBUG) { maxConnections.setOnPreferenceChangeListener(this) maxConnections.setSummary(prefs.getString( @@ -70,6 +76,7 @@ class SettingsFragment extends PreferenceFragment with OnPreferenceChangeListene val service = getActivity.asInstanceOf[EnsichatActivity].service val ui = new UserInfo(prefs.getString(KeyUserName, ""), prefs.getString(KeyUserStatus, "")) database.getContacts.foreach(c => service.sendTo(c.address, ui)) + case _ => // TODO: this correct? (check before rebase) } preference.setSummary(newValue.toString) true diff --git a/app/src/main/scala/com/nutomic/ensichat/protocol/ChatService.scala b/app/src/main/scala/com/nutomic/ensichat/protocol/ChatService.scala index 8070b88..4ccd457 100644 --- a/app/src/main/scala/com/nutomic/ensichat/protocol/ChatService.scala +++ b/app/src/main/scala/com/nutomic/ensichat/protocol/ChatService.scala @@ -13,7 +13,7 @@ import com.nutomic.ensichat.R import com.nutomic.ensichat.activities.MainActivity import com.nutomic.ensichat.bluetooth.BluetoothInterface import com.nutomic.ensichat.fragments.SettingsFragment -import com.nutomic.ensichat.protocol.body.{ConnectionInfo, MessageBody, UserInfo} +import com.nutomic.ensichat.protocol.body.{ConnectionInfo, MessageBody, UserInfo, _} import com.nutomic.ensichat.protocol.header.ContentHeader import com.nutomic.ensichat.util.{Database, FutureHelper, NotificationHandler} @@ -119,7 +119,7 @@ class ChatService extends Service { FutureHelper { val messageId = preferences.getLong("message_id", 0) val header = new ContentHeader(crypto.localAddress, target, seqNumGenerator.next(), - body.contentType, Some(messageId), Some(new Date())) + body.contentType, Some(messageId), Some(new Date()), true) preferences.edit().putLong("message_id", messageId + 1) val msg = new Message(header, body) @@ -151,25 +151,28 @@ class ChatService extends Service { /** * Handles all (locally and remotely sent) new messages. */ - private def onNewMessage(msg: Message): Unit = msg.body match { - case ui: UserInfo => - val contact = new User(msg.header.origin, ui.name, ui.status) - knownUsers += contact - if (database.getContact(msg.header.origin).nonEmpty) - database.updateContact(contact) + private def onNewMessage(msg: Message): Unit = { - callConnectionListeners() - case _ => - val origin = msg.header.origin - if (origin != crypto.localAddress && database.getContact(origin).isEmpty) - database.addContact(getUser(origin)) + // FIXME: looks like we completely broke message sending - database.onMessageReceived(msg) - notificationHandler.onMessageReceived(msg) - val i = new Intent(ChatService.ActionMessageReceived) - i.putExtra(ChatService.ExtraMessage, msg) - LocalBroadcastManager.getInstance(this) - .sendBroadcast(i) + msg.body match { + case ui: UserInfo => + val contact = new User(msg.header.origin, ui.name, ui.status) + knownUsers += contact + if (database.getContact(msg.header.origin).nonEmpty) + database.updateContact(contact) + case _: InitiatePayment | _: PaymentInformation | _: Text => + val origin = msg.header.origin + if (origin != crypto.localAddress && database.getContact(origin).isEmpty) + database.addContact(getUser(origin)) + + val i = new Intent(ChatService.ActionMessageReceived) + i.putExtra(ChatService.ExtraMessage, msg) + LocalBroadcastManager.getInstance(this) + .sendBroadcast(i) + case _: ConnectionInfo => + callConnectionListeners() + } } /** diff --git a/app/src/main/scala/com/nutomic/ensichat/protocol/Crypto.scala b/app/src/main/scala/com/nutomic/ensichat/protocol/Crypto.scala index 747da08..15382d8 100644 --- a/app/src/main/scala/com/nutomic/ensichat/protocol/Crypto.scala +++ b/app/src/main/scala/com/nutomic/ensichat/protocol/Crypto.scala @@ -254,8 +254,10 @@ class Crypto(context: Context) { symmetricCipher.init(Cipher.DECRYPT_MODE, key) val decrypted = copyThroughCipher(symmetricCipher, msg.body.asInstanceOf[EncryptedBody].data) val body = msg.header.asInstanceOf[ContentHeader].contentType match { - case Text.Type => Text.read(decrypted) - case UserInfo.Type => UserInfo.read(decrypted) + case Text.Type => Text.read(decrypted) + case UserInfo.Type => UserInfo.read(decrypted) + case InitiatePayment.Type => InitiatePayment.read(decrypted) + case PaymentInformation.Type => PaymentInformation.read(decrypted) } new Message(msg.header, msg.crypto, body) } diff --git a/app/src/main/scala/com/nutomic/ensichat/protocol/Router.scala b/app/src/main/scala/com/nutomic/ensichat/protocol/Router.scala index 27716ae..ffe35d4 100644 --- a/app/src/main/scala/com/nutomic/ensichat/protocol/Router.scala +++ b/app/src/main/scala/com/nutomic/ensichat/protocol/Router.scala @@ -1,6 +1,6 @@ package com.nutomic.ensichat.protocol -import com.nutomic.ensichat.protocol.header.{MessageHeader, ContentHeader} +import com.nutomic.ensichat.protocol.header.{ContentHeader, MessageHeader} /** * Forwards messages to all connected devices. @@ -30,7 +30,7 @@ class Router(activeConnections: () => Set[Address], send: (Address, Message) => private def incHopCount(msg: Message): Message = { val updatedHeader = msg.header match { case ch: ContentHeader => new ContentHeader(ch.origin, ch.target, ch.seqNum, ch.contentType, - ch.messageId, ch.time, ch.hopCount + 1, ch.hopLimit) + ch.messageId, ch.time, ch.read, ch.hopCount + 1, ch.hopLimit) case mh: MessageHeader => new MessageHeader(mh.protocolType, mh.origin, mh.target, mh.seqNum, mh.hopCount + 1, mh.hopLimit) } diff --git a/app/src/main/scala/com/nutomic/ensichat/protocol/body/InitiatePayment.scala b/app/src/main/scala/com/nutomic/ensichat/protocol/body/InitiatePayment.scala new file mode 100644 index 0000000..fa07f80 --- /dev/null +++ b/app/src/main/scala/com/nutomic/ensichat/protocol/body/InitiatePayment.scala @@ -0,0 +1,34 @@ +package com.nutomic.ensichat.protocol.body + +import java.nio.ByteBuffer + +object InitiatePayment { + + val Type = 5 + + /** + * Constructs [[InitiatePayment]] instance from byte array. + */ + def read(array: Array[Byte]): InitiatePayment = { + new InitiatePayment() + } + +} + +/** + * Sent to initiate a bitcoin payment. This should get a [[PaymentInformation]] as a response. + */ +case class InitiatePayment() extends MessageBody { + + override def protocolType = -1 + + override def contentType = InitiatePayment.Type + + override def write: Array[Byte] = { + val b = ByteBuffer.allocate(length) + b.array() + } + + override def length = 4 + +} diff --git a/app/src/main/scala/com/nutomic/ensichat/protocol/body/PaymentInformation.scala b/app/src/main/scala/com/nutomic/ensichat/protocol/body/PaymentInformation.scala new file mode 100644 index 0000000..c1f7816 --- /dev/null +++ b/app/src/main/scala/com/nutomic/ensichat/protocol/body/PaymentInformation.scala @@ -0,0 +1,53 @@ +package com.nutomic.ensichat.protocol.body + +import java.nio.ByteBuffer +import java.util + +import com.nutomic.ensichat.util.BufferUtils + +object PaymentInformation { + + val Type = 6 + + /** + * Constructs [[PaymentInformation]] instance from byte array. + */ + def read(array: Array[Byte]): PaymentInformation = { + val b = ByteBuffer.wrap(array) + val length = BufferUtils.getUnsignedInt(b).toInt + val bytes = new Array[Byte](length) + b.get(bytes, 0, length) + new PaymentInformation(bytes) + } + +} + +/** + * Contains bitcoin payment information so bitcoins can be sent to the origin of this message. + * + * @param bytes Protobuf-formatted Bitcoin payment message. + */ +case class PaymentInformation(bytes: Array[Byte]) + extends MessageBody { + + override def protocolType = -1 + + override def contentType = PaymentInformation.Type + + override def write: Array[Byte] = { + val b = ByteBuffer.allocate(length) + BufferUtils.putUnsignedInt(b, bytes.length) + b.put(bytes) + b.array() + } + + override def length = 4 + bytes.length + + override def equals(a: Any): Boolean = a match { + case o: PaymentInformation => util.Arrays.equals(bytes, o.bytes) + case _ => false + } + + override def toString = BufferUtils.toString(bytes) + +} diff --git a/app/src/main/scala/com/nutomic/ensichat/protocol/body/Text.scala b/app/src/main/scala/com/nutomic/ensichat/protocol/body/Text.scala index d3fb716..0c9c2d7 100644 --- a/app/src/main/scala/com/nutomic/ensichat/protocol/body/Text.scala +++ b/app/src/main/scala/com/nutomic/ensichat/protocol/body/Text.scala @@ -7,7 +7,7 @@ import com.nutomic.ensichat.util.BufferUtils object Text { - val Type = 6 + val Type = 3 /** * Constructs [[Text]] instance from byte array. diff --git a/app/src/main/scala/com/nutomic/ensichat/protocol/header/ContentHeader.scala b/app/src/main/scala/com/nutomic/ensichat/protocol/header/ContentHeader.scala index 538549b..7e055c9 100644 --- a/app/src/main/scala/com/nutomic/ensichat/protocol/header/ContentHeader.scala +++ b/app/src/main/scala/com/nutomic/ensichat/protocol/header/ContentHeader.scala @@ -25,7 +25,7 @@ object ContentHeader { val time = BufferUtils.getUnsignedInt(b) val ch = new ContentHeader(mh.origin, mh.target, mh.seqNum, contentType, Some(messageId), - Some(new Date(time * 1000)), mh.hopCount) + Some(new Date(time * 1000)), false, mh.hopCount) val remaining = new Array[Byte](b.remaining()) b.get(remaining, 0, b.remaining()) @@ -38,6 +38,8 @@ object ContentHeader { * Header for user-sent messages. * * This is [[AbstractHeader]] with messageId and time fields set. + * + * @param read Specifies if the message was read by the local user. Never transmitted. */ case class ContentHeader(override val origin: Address, override val target: Address, @@ -45,6 +47,7 @@ case class ContentHeader(override val origin: Address, contentType: Int, override val messageId: Some[Long], override val time: Some[Date], + read: Boolean, override val hopCount: Int = 0, override val hopLimit: Int = AbstractHeader.DefaultHopLimit) extends AbstractHeader { @@ -73,7 +76,8 @@ case class ContentHeader(override val origin: Address, super.equals(a) && contentType == o.contentType && messageId == o.messageId && - time.get.getTime / 1000 == o.time.get.getTime / 1000 + time.get.getTime / 1000 == o.time.get.getTime / 1000 && + read == o.read case _ => false } diff --git a/app/src/main/scala/com/nutomic/ensichat/protocol/header/MessageHeader.scala b/app/src/main/scala/com/nutomic/ensichat/protocol/header/MessageHeader.scala index 2074957..c56d343 100644 --- a/app/src/main/scala/com/nutomic/ensichat/protocol/header/MessageHeader.scala +++ b/app/src/main/scala/com/nutomic/ensichat/protocol/header/MessageHeader.scala @@ -2,8 +2,8 @@ package com.nutomic.ensichat.protocol.header import java.nio.ByteBuffer -import com.nutomic.ensichat.protocol.{Message, Address} -import Message.ParseMessageException +import com.nutomic.ensichat.protocol.Message.ParseMessageException +import com.nutomic.ensichat.protocol.{Address, Message} import com.nutomic.ensichat.util.BufferUtils object MessageHeader { diff --git a/app/src/main/scala/com/nutomic/ensichat/util/Database.scala b/app/src/main/scala/com/nutomic/ensichat/util/Database.scala index 7fe6bea..5906a53 100644 --- a/app/src/main/scala/com/nutomic/ensichat/util/Database.scala +++ b/app/src/main/scala/com/nutomic/ensichat/util/Database.scala @@ -7,7 +7,7 @@ import android.database.Cursor import android.database.sqlite.{SQLiteDatabase, SQLiteOpenHelper} import android.support.v4.content.LocalBroadcastManager import com.nutomic.ensichat.protocol._ -import com.nutomic.ensichat.protocol.body.Text +import com.nutomic.ensichat.protocol.body.{Text, _} import com.nutomic.ensichat.protocol.header.ContentHeader import scala.collection.SortedSet @@ -24,28 +24,43 @@ object Database { // NOTE: We could make origin/target foreign keys to contacts, but: // - they don't change anyway // - we'd have to insert the local user into contacts - private val CreateMessagesTable = "CREATE TABLE messages(" + + private val CreateTableMessages = "CREATE TABLE messages(" + "_id INTEGER PRIMARY KEY," + "origin TEXT NOT NULL," + "target TEXT NOT NULL," + "message_id INT NOT NULL," + - "text TEXT NOT NULL," + - "date INT NOT NULL);" // Unix timestamp + "type INT NOT NULL," + + "date INT NOT NULL," + // Unix timestamp + "read INT NOT NULL);" - private val CreateContactsTable = "CREATE TABLE contacts(" + + private val CreateTableTexts = "CREATE TABLE texts(" + + "_id INTEGER PRIMARY KEY," + + "message_id INT," + + "text TEXT NOT NULL," + + "FOREIGN KEY (message_id) REFERENCES messages(_id))" + + private val CreateTablePaymentRequests = "CREATE TABLE payment_requests(" + + "_id INTEGER PRIMARY KEY," + + "message_id INT," + + "bytes TEXT NOT NULL," + + "FOREIGN KEY (message_id) REFERENCES messages(_id))" + + private val CreateTableContacts = "CREATE TABLE contacts(" + "_id INTEGER PRIMARY KEY," + "address TEXT NOT NULL," + "name TEXT NOT NULL," + "status TEXT NOT NULL)" - def messageFromCursor(c: Cursor): Message = { + // TODO: only used for fragment right now, merge with other code (and make general method for all msgs) + def textMessageFromCursor(c: Cursor): Message = { val header = new ContentHeader(new Address( c.getString(c.getColumnIndex("origin"))), new Address(c.getString(c.getColumnIndex("target"))), -1, Text.Type, Some(c.getLong(c.getColumnIndex("message_id"))), - Some(new Date(c.getLong(c.getColumnIndex("date"))))) + Some(new Date(c.getLong(c.getColumnIndex("date")))), + c.getInt(c.getColumnIndex("read")) == 1) val body = new Text(new String(c.getString(c.getColumnIndex ("text")))) new Message(header, body) } @@ -58,14 +73,20 @@ object Database { class Database(context: Context) extends SQLiteOpenHelper(context, Database.DatabaseName, null, Database.DatabaseVersion) { + override def onConfigure(db: SQLiteDatabase): Unit = { + db.execSQL("PRAGMA foreign_keys = ON;") + } + override def onCreate(db: SQLiteDatabase): Unit = { - db.execSQL(Database.CreateContactsTable) - db.execSQL(Database.CreateMessagesTable) + db.execSQL(Database.CreateTableMessages) + db.execSQL(Database.CreateTableTexts) + db.execSQL(Database.CreateTablePaymentRequests) + db.execSQL(Database.CreateTableContacts) } def getMessagesCursor(address: Address, count: Option[Int]): Cursor = { - getReadableDatabase.query(true, - "messages", Array("_id", "origin", "target", "message_id", "text", "date"), + getReadableDatabase.query(true, "messages", + Array("_id", "origin", "target", "type", "message_id", "date", "read"), "origin = ? OR target = ?", Array(address.toString, address.toString), null, null, "date ASC", count.map(_.toString).orNull) } @@ -77,7 +98,31 @@ class Database(context: Context) val c = getMessagesCursor(address, Option(count)) var messages = new TreeSet[Message]()(Message.Ordering) while (c.moveToNext()) { - messages += Database.messageFromCursor(c) + val header = new ContentHeader( + new Address(c.getString(c.getColumnIndex("origin"))), + new Address(c.getString(c.getColumnIndex("target"))), + -1, + c.getInt(c.getColumnIndex("type")), + Some(c.getLong(c.getColumnIndex("message_id"))), + Some(new Date(c.getLong(c.getColumnIndex("date")))), + c.getInt(c.getColumnIndex("read")) == 1) + + val id = c.getString(c.getColumnIndex("_id")) + val body = header.contentType match { + case Text.Type => + val c2 = getReadableDatabase.query( + "texts", Array("text"), "message_id=?", Array(id), null, null, null) + c2.moveToFirst() + new Text(new String(c2.getString(c2.getColumnIndex ("text")))) + case InitiatePayment.Type => + new InitiatePayment() + case PaymentInformation.Type => + val c2 = getReadableDatabase.query( + "payment_requests", Array("bytes"), "message_id=?", Array(id), null, null, null) + c2.moveToFirst() + new PaymentInformation(c2.getBlob(c2.getColumnIndex("bytes"))) + } + messages += new Message(header, body) } c.close() messages @@ -86,16 +131,46 @@ class Database(context: Context) /** * Inserts the given new message into the database. */ - def onMessageReceived(msg: Message): Unit = msg.body match { - case text: Text => - val cv = new ContentValues() - cv.put("origin", msg.header.origin.toString) - cv.put("target", msg.header.target.toString) - // Need to use [[Long#toString]] because of https://issues.scala-lang.org/browse/SI-2991 - cv.put("message_id", msg.header.messageId.get.toString) - cv.put("date", msg.header.time.get.getTime.toString) - cv.put("text", text.text) - getWritableDatabase.insert("messages", null, cv) + def onMessageReceived(msg: Message): Unit = { + // Only certain types of messages are stored. + val types: Set[Class[_]] = + Set(classOf[Text], classOf[InitiatePayment], classOf[PaymentInformation]) + if (!types.contains(msg.body.getClass)) + return + + val header = msg.header.asInstanceOf[ContentHeader] + val cv = new ContentValues() + cv.put("origin", header.origin.toString) + cv.put("target", header.target.toString) + // Need to use [[Long#toString]] because of https://issues.scala-lang.org/browse/SI-2991 + cv.put("message_id", header.messageId.toString) + cv.put("type", header.contentType.toString) + cv.put("date", header.time.get.getTime.toString) + cv.put("read", header.read) + + val id = getWritableDatabase.insert("messages", null, cv) + + val cvExtra = new ContentValues() + cvExtra.put("message_id", id.toString) + msg.body match { + case text: Text => + cvExtra.put("text", text.text) + getWritableDatabase.insert("texts", null, cvExtra) + case pr: PaymentInformation => + cvExtra.put("bytes", pr.bytes) + getWritableDatabase.insert("payment_requests", null, cvExtra) + case _: InitiatePayment => + } + } + + /** + * Marks the message as read by the user. + */ + def setMessageRead(header: ContentHeader): Unit = { + val cv = new ContentValues() + cv.put("read", "1") + getReadableDatabase.update("messages", cv, "origin=? AND message_id=?", + Array(header.origin.toString, header.messageId.toString)) } /** diff --git a/app/src/main/scala/com/nutomic/ensichat/util/FutureHelper.scala b/app/src/main/scala/com/nutomic/ensichat/util/FutureHelper.scala index 6b50d45..25ce849 100644 --- a/app/src/main/scala/com/nutomic/ensichat/util/FutureHelper.scala +++ b/app/src/main/scala/com/nutomic/ensichat/util/FutureHelper.scala @@ -1,11 +1,11 @@ package com.nutomic.ensichat.util -import android.os.{Looper, Handler} +import android.os.{Handler, Looper} import scala.concurrent.{ExecutionContext, Future} /** - * Wraps [[Future]], so that exceptions are always thrown. + * Use this instead of [[Future]], to make sure exceptions are logged. * * @see https://github.com/saturday06/gradle-android-scala-plugin/issues/56 */ diff --git a/app/src/main/scala/com/nutomic/ensichat/util/NotificationHandler.scala b/app/src/main/scala/com/nutomic/ensichat/util/NotificationHandler.scala index 3511156..d1c3917 100644 --- a/app/src/main/scala/com/nutomic/ensichat/util/NotificationHandler.scala +++ b/app/src/main/scala/com/nutomic/ensichat/util/NotificationHandler.scala @@ -7,6 +7,7 @@ import android.support.v4.app.NotificationCompat import com.nutomic.ensichat.R import com.nutomic.ensichat.activities.MainActivity import com.nutomic.ensichat.protocol.body.Text +import com.nutomic.ensichat.protocol.body.{InitiatePayment, PaymentInformation, Text} import com.nutomic.ensichat.protocol.{Crypto, Message} object NotificationHandler { @@ -22,25 +23,32 @@ object NotificationHandler { */ class NotificationHandler(context: Context) { - def onMessageReceived(msg: Message): Unit = msg.body match { - case text: Text => - if (msg.header.origin == new Crypto(context).localAddress) - return + def onMessageReceived(msg: Message): Unit = { + if (msg.header.origin == new Crypto(context).localAddress) + return - val pi = PendingIntent.getActivity(context, 0, new Intent(context, classOf[MainActivity]), 0) - val notification = new NotificationCompat.Builder(context) - .setSmallIcon(R.drawable.ic_launcher) - .setContentTitle(context.getString(R.string.notification_message)) - .setContentText(text.text) - .setDefaults(defaults()) - .setContentIntent(pi) - .setAutoCancel(true) - .build() + showNotification(msg.body match { + case t: Text => t.text + case _: InitiatePayment => "InitiatePayment" + case _: PaymentInformation => "PaymentInformation" + case _ => return + }) + } - val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) - .asInstanceOf[NotificationManager] - nm.notify(NotificationHandler.NotificationIdNewMessage, notification) - case _ => + private def showNotification(text: String): Unit = { + val pi = PendingIntent.getActivity(context, 0, new Intent(context, classOf[MainActivity]), 0) + val notification = new NotificationCompat.Builder(context) + .setSmallIcon(R.drawable.ic_launcher) + .setContentTitle(context.getString(R.string.notification_message)) + .setContentText(text) + .setDefaults(defaults()) + .setDefaults(defaults()) + .setContentIntent(pi) + .setAutoCancel(true) + .build() + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) + .asInstanceOf[NotificationManager] + nm.notify(NotificationHandler.NotificationIdNewMessage, notification) } /** diff --git a/app/src/main/scala/com/nutomic/ensichat/views/DatesAdapter.scala b/app/src/main/scala/com/nutomic/ensichat/views/DatesAdapter.scala index b0f143c..2abc7f1 100644 --- a/app/src/main/scala/com/nutomic/ensichat/views/DatesAdapter.scala +++ b/app/src/main/scala/com/nutomic/ensichat/views/DatesAdapter.scala @@ -17,7 +17,7 @@ object DatesAdapter { override def getSectionTitleForItem(item: Cursor): String = { DateFormat .getDateInstance(DateFormat.MEDIUM) - .format(Database.messageFromCursor(item).header.time.get) + .format(Database.textMessageFromCursor(item).header.time.get) } } diff --git a/app/src/main/scala/com/nutomic/ensichat/views/MessagesAdapter.scala b/app/src/main/scala/com/nutomic/ensichat/views/MessagesAdapter.scala index 5c55b4d..df36925 100644 --- a/app/src/main/scala/com/nutomic/ensichat/views/MessagesAdapter.scala +++ b/app/src/main/scala/com/nutomic/ensichat/views/MessagesAdapter.scala @@ -52,6 +52,6 @@ class MessagesAdapter(context: Context, cursor: Cursor, remoteAddress: Address) } }) - override def getInstance (cursor: Cursor) = Database.messageFromCursor(cursor) + override def getInstance (cursor: Cursor) = Database.textMessageFromCursor(cursor) }