Allow sending bitcoins in chat.

This commit is contained in:
Felix Ableitner 2015-04-24 00:43:21 +02:00
parent 284a4ceb02
commit c350ac12e0
32 changed files with 736 additions and 118 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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("", ""))
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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 = {

View file

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -36,6 +36,13 @@
android:id="@+id/linearLayout"
android:orientation="horizontal">
<ImageButton
android:layout_width="45dp"
android:layout_height="45dp"
android:id="@+id/send_bitcoin"
android:src="@drawable/ic_bitcoin"
android:background="@android:color/transparent"/>
<EditText
android:id="@+id/message"
android:layout_width="0dp"

View file

@ -3,10 +3,17 @@
<string name="default_user_status">Let\'s chat!</string>
<bool name="default_notification_sounds">true</bool>
<string name="default_scan_interval">15</string>
<bool name="default_notification_sounds">true</bool>
<string name="default_max_connections">1000000</string>
</resources>
<string-array name="bitcoin_wallets">
<item>de.schildbach.wallet</item>
<item>de.schildbach.wallet_test</item>
</string-array>
<string name="default_bitcoin_wallet">de.schildbach.wallet</string>
</resources>

View file

@ -52,6 +52,11 @@
<string name="toast_contact_added">Contact added</string>
<!-- ChatFragment -->
<string name="bitcoin_wallet_not_found">Bitcoin wallet not found. Please install wallet app or select another wallet in the app settings.</string>
<!-- SettingsActivity -->
<!-- Activity title -->
@ -72,6 +77,14 @@
<!-- Preference title -->
<string name="notification_sounds">Notification Sounds</string>
<string name="bitcoin_wallet">Bitcoin Wallet Application</string>
<!-- List of supported wallet apps -->
<string-array name="bitcoin_wallet_values">
<item>Bitcoin Wallet</item>
<item>Bitcoin Wallet for Testnet</item>
</string-array>
<!-- Preference title (debug only)-->
<string name="max_connections" translatable="false">Maximum Number of Connections</string>

View file

@ -22,6 +22,13 @@
android:inputType="number"
android:numeric="integer" />
<ListPreference
android:title="@string/bitcoin_wallet"
android:key="bitcoin_wallet"
android:entries="@array/bitcoin_wallet_values"
android:entryValues="@array/bitcoin_wallets"
android:defaultValue="@string/default_bitcoin_wallet" />
<EditTextPreference
android:title="@string/max_connections"
android:key="max_connections"
@ -39,6 +46,7 @@
<Preference
android:title="@string/version"
android:key="version" />
android:key="version"
style="?android:preferenceInformationStyle" />
</PreferenceScreen>

View file

@ -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.

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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()
}
}
/**

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -7,7 +7,7 @@ import com.nutomic.ensichat.util.BufferUtils
object Text {
val Type = 6
val Type = 3
/**
* Constructs [[Text]] instance from byte array.

View file

@ -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
}

View file

@ -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 {

View file

@ -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))
}
/**

View file

@ -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
*/

View file

@ -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)
}
/**

View file

@ -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)
}
}

View file

@ -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)
}