/*
* Aegis Bitcoin Wallet - The secure Bitcoin wallet for Android
* Copyright 2014 Bojan Simic and specularX.co, designed by Reuven Yamrom
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.aegiswallet.services;
import android.annotation.SuppressLint;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.net.ConnectivityManager;
import android.os.Binder;
import android.os.Handler;
import android.os.IBinder;
import android.preference.PreferenceManager;
import android.support.v4.app.NotificationCompat;
import android.util.Log;
import com.aegiswallet.PayBitsApplication;
import com.aegiswallet.R;
import com.aegiswallet.actions.MainActivity;
import com.aegiswallet.utils.Constants;
import com.aegiswallet.utils.WalletUtils;
import com.google.bitcoin.core.AbstractPeerEventListener;
import com.google.bitcoin.core.Address;
import com.google.bitcoin.core.AddressFormatException;
import com.google.bitcoin.core.Block;
import com.google.bitcoin.core.BlockChain;
import com.google.bitcoin.core.CheckpointManager;
import com.google.bitcoin.core.InsufficientMoneyException;
import com.google.bitcoin.core.Peer;
import com.google.bitcoin.core.PeerEventListener;
import com.google.bitcoin.core.PeerGroup;
import com.google.bitcoin.core.StoredBlock;
import com.google.bitcoin.core.Transaction;
import com.google.bitcoin.core.Wallet;
import com.google.bitcoin.crypto.KeyCrypterException;
import com.google.bitcoin.net.discovery.DnsDiscovery;
import com.google.bitcoin.net.discovery.PeerDiscovery;
import com.google.bitcoin.net.discovery.PeerDiscoveryException;
import com.google.bitcoin.store.BlockStore;
import com.google.bitcoin.store.BlockStoreException;
import com.google.bitcoin.store.SPVBlockStore;
import com.google.common.util.concurrent.ListenableFuture;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import javax.annotation.CheckForNull;
/**
* Created by bsimic on 2/13/14.
*/
public class PeerBlockchainService extends android.app.Service {
private PayBitsApplication application;
private SharedPreferences prefs;
private NotificationManager nm;
private File blockChainFile;
private BlockStore blockStore;
private BlockChain blockChain;
@CheckForNull
private PeerGroup peerGroup;
private PeerConnectivityListener peerConnectivityListener;
private final Handler handler = new Handler();
public static final String PREFS_KEY_CONNECTIVITY_NOTIFICATION = "connectivity_notification";
public static final String ACTION_PEER_STATE = R.class.getPackage().getName() + ".peer_state";
public static final String ACTION_PEER_STATE_NUM_PEERS = "num_peers";
private final Handler delayHandler = new Handler();
private int bestChainHeightEver;
private AtomicInteger transactionsReceived = new AtomicInteger();
private int notificationCount = 0;
private BigInteger notificationAccumulatedAmount = BigInteger.ZERO;
private final List<Address> notificationAddresses = new LinkedList<Address>();
private boolean resetBlockchainOnShutdown = false;
public static final String ACTION_BLOCKCHAIN_STATE = R.class.getPackage().getName() + ".blockchain_state";
public static final String ACTION_BLOCKCHAIN_STATE_BEST_CHAIN_DATE = "best_chain_date";
public static final String ACTION_BLOCKCHAIN_STATE_BEST_CHAIN_HEIGHT = "best_chain_height";
public static final String ACTION_BLOCKCHAIN_STATE_REPLAYING = "replaying";
public static final String ACTION_BLOCKCHAIN_STATE_DOWNLOAD = "download";
public static final int ACTION_BLOCKCHAIN_STATE_DOWNLOAD_OK = 0;
public static final int ACTION_BLOCKCHAIN_STATE_DOWNLOAD_STORAGE_PROBLEM = 1;
public static final int ACTION_BLOCKCHAIN_STATE_DOWNLOAD_NETWORK_PROBLEM = 2;
private static final int IDLE_BLOCK_TIMEOUT_MIN = 2;
private static final int IDLE_TRANSACTION_TIMEOUT_MIN = 9;
private static final int MAX_HISTORY_SIZE = Math.max(IDLE_TRANSACTION_TIMEOUT_MIN, IDLE_BLOCK_TIMEOUT_MIN);
private static final int MIN_COLLECT_HISTORY = 2;
public static final String ACTION_CANCEL_COINS_RECEIVED = R.class.getPackage().getName() + ".cancel_coins_received";
private static final int NOTIFICATION_ID_CONNECTED = 0;
private static final int NOTIFICATION_ID_COINS_RECEIVED = 1;
public static final String ACTION_RESET_BLOCKCHAIN = R.class.getPackage().getName() + ".reset_blockchain";
public static final String ACTION_BROADCAST_TRANSACTION = R.class.getPackage().getName() + ".broadcast_transaction";
private static final Logger log = LoggerFactory.getLogger(PeerBlockchainService.class);
private final IBinder mBinder = new LocalBinder();
private SharedPreferences tagPrefs;
private static final String TAG = PeerBlockchainService.class.getName();
@Override
public void onCreate() {
super.onCreate();
nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
application = (PayBitsApplication) getApplication();
prefs = PreferenceManager.getDefaultSharedPreferences(this);
final Wallet wallet = application.getWallet();
blockChainFile = new File(getDir("blockstore", Context.MODE_PRIVATE), Constants.BLOCKCHAIN_FILENAME);
final boolean blockChainFileExists = blockChainFile.exists();
if (!blockChainFileExists) {
wallet.clearTransactions(0);
wallet.setLastBlockSeenHeight(-1);
wallet.setLastBlockSeenHash(null);
}
try {
blockStore = new SPVBlockStore(Constants.NETWORK_PARAMETERS, blockChainFile);
blockStore.getChainHead(); // detect corruptions as early as possible
long earliestKeyCreationTime = wallet.getEarliestKeyCreationTime();
if (earliestKeyCreationTime == 0)
earliestKeyCreationTime = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7);
if (!blockChainFileExists && earliestKeyCreationTime > 0) {
Log.d(TAG, "creating blockchain from checkpoint. attmpting to at least...");
try {
final InputStream checkpointsInputStream = getAssets().open(Constants.CHECKPOINTS_FILENAME);
CheckpointManager.checkpoint(Constants.NETWORK_PARAMETERS, checkpointsInputStream, blockStore, earliestKeyCreationTime);
} catch (final IOException x) {
Log.e(TAG, "problem reading checkpoint file..." + x.getMessage());
}
}
} catch (final BlockStoreException x) {
blockChainFile.delete();
final String msg = "blockstore cannot be created";
throw new Error(msg, x);
}
try {
blockChain = new BlockChain(Constants.NETWORK_PARAMETERS, wallet, blockStore);
} catch (final BlockStoreException x) {
throw new Error("blockchain cannot be created", x);
}
bestChainHeightEver = prefs.getInt(Constants.PREFS_KEY_BEST_CHAIN_HEIGHT_EVER, 0);
peerConnectivityListener = new PeerConnectivityListener();
sendBroadcastPeerState(0);
final IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION);
intentFilter.addAction(Intent.ACTION_DEVICE_STORAGE_LOW);
intentFilter.addAction(Intent.ACTION_DEVICE_STORAGE_OK);
registerReceiver(connectivityReceiver, intentFilter);
registerReceiver(tickReceiver, new IntentFilter(Intent.ACTION_TIME_TICK));
maybeRotateKeys();
tagPrefs = application.getSharedPreferences(
getString(R.string.tag_pref_filename), Context.MODE_PRIVATE);
}
@Override
public void onDestroy() {
unregisterReceiver(tickReceiver);
if (peerGroup != null) {
peerGroup.removeEventListener(peerConnectivityListener);
peerGroup.removeWallet(application.getWallet());
peerGroup.stopAndWait();
}
peerConnectivityListener.stop();
unregisterReceiver(connectivityReceiver);
removeBroadcastPeerState();
removeBroadcastBlockchainState();
prefs.edit().putInt(Constants.PREFS_KEY_BEST_CHAIN_HEIGHT_EVER, bestChainHeightEver).commit();
delayHandler.removeCallbacksAndMessages(null);
try {
blockStore.close();
} catch (final BlockStoreException x) {
throw new RuntimeException(x);
}
//Removing blockchain.
if (resetBlockchainOnShutdown) {
Log.d(TAG, "STOPPING SERVICE, DELETING BC");
blockChainFile.delete();
}
super.onDestroy();
}
public class LocalBinder extends Binder {
public PeerBlockchainService getService() {
return PeerBlockchainService.this;
}
}
@Override
public IBinder onBind(final Intent intent) {
return mBinder;
}
@Override
public boolean onUnbind(final Intent intent) {
return super.onUnbind(intent);
}
private final class PeerConnectivityListener extends AbstractPeerEventListener implements SharedPreferences.OnSharedPreferenceChangeListener {
private int peerCount;
private AtomicBoolean stopped = new AtomicBoolean(false);
public PeerConnectivityListener() {
prefs.registerOnSharedPreferenceChangeListener(this);
}
public void stop() {
stopped.set(true);
prefs.unregisterOnSharedPreferenceChangeListener(this);
nm.cancel(NOTIFICATION_ID_CONNECTED);
}
@Override
public void onPeerConnected(final Peer peer, final int peerCount) {
this.peerCount = peerCount;
changed(peerCount);
}
@Override
public void onPeerDisconnected(final Peer peer, final int peerCount) {
this.peerCount = peerCount;
changed(peerCount);
}
@Override
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) {
if (PREFS_KEY_CONNECTIVITY_NOTIFICATION.equals(key))
changed(peerCount);
}
private void changed(final int numPeers) {
if (stopped.get())
return;
handler.post(new Runnable() {
@Override
public void run() {
if (numPeers == 0) {
nm.cancel(NOTIFICATION_ID_CONNECTED);
} else {
final NotificationCompat.Builder notification = new NotificationCompat.Builder(PeerBlockchainService.this);
notification.setSmallIcon(R.drawable.icon, numPeers > 4 ? 4 : numPeers);
notification.setContentTitle(getString(R.string.app_name));
notification.setContentText(getString(R.string.connected_to_string) + " " + numPeers + " " + getString(R.string.peers_string));
notification.setContentIntent(PendingIntent.getActivity(PeerBlockchainService.this, 0, new Intent(PeerBlockchainService.this,
MainActivity.class), 0));
notification.setOngoing(false);
nm.notify(NOTIFICATION_ID_CONNECTED, notification.getNotification());
}
// send broadcast
sendBroadcastPeerState(numPeers);
}
});
}
}
private final BroadcastReceiver connectivityReceiver = new BroadcastReceiver() {
private boolean hasConnectivity;
private boolean hasStorage = true;
@Override
public void onReceive(final Context context, final Intent intent) {
final String action = intent.getAction();
if (ConnectivityManager.CONNECTIVITY_ACTION.equals(action)) {
hasConnectivity = !intent.getBooleanExtra(ConnectivityManager.EXTRA_NO_CONNECTIVITY, false);
log.info("network is " + (hasConnectivity ? "up" : "down"));
check();
} else if (Intent.ACTION_DEVICE_STORAGE_LOW.equals(action)) {
hasStorage = false;
check();
} else if (Intent.ACTION_DEVICE_STORAGE_OK.equals(action)) {
hasStorage = true;
check();
}
}
@SuppressLint("Wakelock")
private void check() {
final Wallet wallet = application.getWallet();
final boolean hasEverything = hasConnectivity && hasStorage;
if (hasEverything && peerGroup == null) {
peerGroup = new PeerGroup(Constants.NETWORK_PARAMETERS, blockChain);
peerGroup.addWallet(wallet);
peerGroup.setUserAgent("Aegis Wallet", "1.0");
peerGroup.addEventListener(peerConnectivityListener);
final int maxConnectedPeers = 10;
peerGroup.setMaxConnections(maxConnectedPeers);
peerGroup.addPeerDiscovery(new PeerDiscovery() {
private final PeerDiscovery normalPeerDiscovery = new DnsDiscovery(Constants.NETWORK_PARAMETERS);
@Override
public InetSocketAddress[] getPeers(final long timeoutValue, final TimeUnit timeoutUnit) throws PeerDiscoveryException {
final List<InetSocketAddress> peers = new LinkedList<InetSocketAddress>();
boolean needsTrimPeersWorkaround = false;
//TODO: remove this...using for tesnet connection to peers issue
//InetSocketAddress customPeer = new InetSocketAddress("54.243.211.176",18333);
//peers.add(customPeer);
//End todo
peers.addAll(Arrays.asList(normalPeerDiscovery.getPeers(timeoutValue, timeoutUnit)));
if (needsTrimPeersWorkaround)
while (peers.size() >= maxConnectedPeers)
peers.remove(peers.size() - 1);
return peers.toArray(new InetSocketAddress[0]);
}
@Override
public void shutdown() {
normalPeerDiscovery.shutdown();
}
});
peerGroup.start();
peerGroup.startBlockChainDownload(blockchainDownloadListener);
} else if (!hasEverything && peerGroup != null) {
peerGroup.removeEventListener(peerConnectivityListener);
peerGroup.removeWallet(wallet);
peerGroup.stop();
peerGroup = null;
}
final int download = (hasConnectivity ? 0 : ACTION_BLOCKCHAIN_STATE_DOWNLOAD_NETWORK_PROBLEM)
| (hasStorage ? 0 : ACTION_BLOCKCHAIN_STATE_DOWNLOAD_STORAGE_PROBLEM);
sendBroadcastBlockchainState(download);
}
};
private final PeerEventListener blockchainDownloadListener = new AbstractPeerEventListener() {
private final AtomicLong lastMessageTime = new AtomicLong(0);
@Override
public void onBlocksDownloaded(final Peer peer, final Block block, final int blocksLeft) {
bestChainHeightEver = Math.max(bestChainHeightEver, blockChain.getChainHead().getHeight());
delayHandler.removeCallbacksAndMessages(null);
final long now = System.currentTimeMillis();
if (now - lastMessageTime.get() > Constants.BLOCKCHAIN_STATE_BROADCAST_THROTTLE_MS)
delayHandler.post(runnable);
else
delayHandler.postDelayed(runnable, Constants.BLOCKCHAIN_STATE_BROADCAST_THROTTLE_MS);
}
private final Runnable runnable = new Runnable() {
@Override
public void run() {
lastMessageTime.set(System.currentTimeMillis());
sendBroadcastBlockchainState(ACTION_BLOCKCHAIN_STATE_DOWNLOAD_OK);
}
};
};
private void removeBroadcastPeerState() {
removeStickyBroadcast(new Intent(ACTION_PEER_STATE));
}
private void removeBroadcastBlockchainState() {
removeStickyBroadcast(new Intent(ACTION_BLOCKCHAIN_STATE));
}
private void sendBroadcastBlockchainState(final int download) {
final StoredBlock chainHead = blockChain.getChainHead();
final Intent broadcast = new Intent(ACTION_BLOCKCHAIN_STATE);
broadcast.setPackage(getPackageName());
broadcast.putExtra(ACTION_BLOCKCHAIN_STATE_BEST_CHAIN_DATE, chainHead.getHeader().getTime());
broadcast.putExtra(ACTION_BLOCKCHAIN_STATE_BEST_CHAIN_HEIGHT, chainHead.getHeight());
broadcast.putExtra(ACTION_BLOCKCHAIN_STATE_REPLAYING, chainHead.getHeight() < bestChainHeightEver);
broadcast.putExtra(ACTION_BLOCKCHAIN_STATE_DOWNLOAD, download);
sendStickyBroadcast(broadcast);
}
private final static class ActivityHistoryEntry {
public final int numTransactionsReceived;
public final int numBlocksDownloaded;
public ActivityHistoryEntry(final int numTransactionsReceived, final int numBlocksDownloaded) {
this.numTransactionsReceived = numTransactionsReceived;
this.numBlocksDownloaded = numBlocksDownloaded;
}
@Override
public String toString() {
return numTransactionsReceived + "/" + numBlocksDownloaded;
}
}
private final BroadcastReceiver tickReceiver = new BroadcastReceiver() {
private int lastChainHeight = 0;
private final List<ActivityHistoryEntry> activityHistory = new LinkedList<ActivityHistoryEntry>();
@Override
public void onReceive(final Context context, final Intent intent) {
final int chainHeight = blockChain.getBestChainHeight();
if (lastChainHeight > 0) {
final int numBlocksDownloaded = chainHeight - lastChainHeight;
final int numTransactionsReceived = transactionsReceived.getAndSet(0);
// push history
activityHistory.add(0, new ActivityHistoryEntry(numTransactionsReceived, numBlocksDownloaded));
// trim
while (activityHistory.size() > MAX_HISTORY_SIZE)
activityHistory.remove(activityHistory.size() - 1);
// print
final StringBuilder builder = new StringBuilder();
for (final ActivityHistoryEntry entry : activityHistory) {
if (builder.length() > 0)
builder.append(", ");
builder.append(entry);
}
// determine if block and transaction activity is idling
boolean isIdle = false;
if (activityHistory.size() >= MIN_COLLECT_HISTORY) {
isIdle = true;
for (int i = 0; i < activityHistory.size(); i++) {
final ActivityHistoryEntry entry = activityHistory.get(i);
final boolean blocksActive = entry.numBlocksDownloaded > 0 && i <= IDLE_BLOCK_TIMEOUT_MIN;
final boolean transactionsActive = entry.numTransactionsReceived > 0 && i <= IDLE_TRANSACTION_TIMEOUT_MIN;
if (blocksActive || transactionsActive) {
isIdle = false;
break;
}
}
}
if (isIdle) {
stopSelf();
}
}
lastChainHeight = chainHeight;
}
};
@Override
public int onStartCommand(final Intent intent, final int flags, final int startId) {
if(intent == null)
return START_NOT_STICKY;
final String action = intent.getAction();
if (PeerBlockchainService.ACTION_CANCEL_COINS_RECEIVED.equals(action)) {
notificationCount = 0;
notificationAccumulatedAmount = BigInteger.ZERO;
notificationAddresses.clear();
nm.cancel(NOTIFICATION_ID_COINS_RECEIVED);
} else if (PeerBlockchainService.ACTION_RESET_BLOCKCHAIN.equals(action)) {
resetBlockchainOnShutdown = true;
stopSelf();
} else if (PeerBlockchainService.ACTION_BROADCAST_TRANSACTION.equals(action)) {
String addressExtra = intent.getStringExtra("address");
String amountExtra = intent.getStringExtra("amount");
boolean justDecrypted = intent.getBooleanExtra("justDecrypted", false);
String tagExtra = intent.getStringExtra("tagText");
BigInteger amountBigInt = new BigInteger(amountExtra);
final Wallet wallet = application.getWallet();
try {
Address address = new Address(Constants.NETWORK_PARAMETERS, addressExtra);
Wallet.SendRequest sendRequest = Wallet.SendRequest.to(address, amountBigInt);
//Adding the tag to the shared prefs
tagPrefs = application.getSharedPreferences(
getString(R.string.tag_pref_filename), Context.MODE_PRIVATE);
tagPrefs.edit().putString(sendRequest.tx.getHashAsString(), tagExtra).commit();
sendRequest.ensureMinRequiredFee = false;
//sendRequest.fee = BigInteger.valueOf(1000);
Transaction transaction = wallet.sendCoinsOffline(sendRequest);
if (transaction != null && peerGroup != null) {
ListenableFuture<Transaction> future = peerGroup.broadcastTransaction(transaction);
//TODO: Maybe doe something with future?
}
} catch (AddressFormatException e) {
Log.e(TAG, "Address format exception " + e.getMessage());
} catch (InsufficientMoneyException e) {
Log.e(TAG, "Insufficient Money Exception " + e.getMessage());
} catch (NullPointerException e) {
Log.e(TAG, "null pointer exception: " + e.getMessage());
}
catch (IllegalArgumentException e){
Log.e(TAG, "illegal argument exception: " + e.getMessage());
}
catch (IllegalStateException e){
Log.e(TAG, "illegal state exception: " + e.getMessage());
}
catch (KeyCrypterException e){
Log.e(TAG, "key crypter exception: " + e.getMessage());
}
finally {
if (justDecrypted) {
if (application.getKeyCache() != null) {
wallet.encrypt(application.getKeyCache().getKeyCrypter(), application.getKeyCache().getAesKey());
String x2 = prefs.getString(Constants.SHAMIR_ENCRYPTED_KEY, null);
if (x2 != null) {
String encryptedX2 = WalletUtils.encryptString(x2, application.getKeyCache().getPassword());
prefs.edit().putString(Constants.SHAMIR_ENCRYPTED_KEY, encryptedX2).commit();
}
}
}
}
}
return START_NOT_STICKY;
}
public List<StoredBlock> getRecentBlocks(final int maxBlocks) {
final List<StoredBlock> blocks = new ArrayList<StoredBlock>(maxBlocks);
try {
StoredBlock block = blockChain.getChainHead();
while (block != null) {
blocks.add(block);
if (blocks.size() >= maxBlocks)
break;
block = block.getPrev(blockStore);
}
} catch (final BlockStoreException x) {
Log.i(TAG, x.getMessage());
// swallow
}
return blocks;
}
private void maybeRotateKeys() {
final Wallet wallet = application.getWallet();
wallet.setKeyRotationEnabled(false);
final StoredBlock chainHead = blockChain.getChainHead();
new Thread() {
@Override
public void run() {
final boolean replaying = chainHead.getHeight() < bestChainHeightEver; // checking again
wallet.setKeyRotationEnabled(!replaying);
}
}.start();
}
private void sendBroadcastPeerState(final int numPeers) {
final Intent broadcast = new Intent(ACTION_PEER_STATE);
broadcast.setPackage(getPackageName());
broadcast.putExtra(ACTION_PEER_STATE_NUM_PEERS, numPeers);
sendStickyBroadcast(broadcast);
}
}