2019-01-22 18:25:45 +00:00
package eu.siacs.conversations.services ;
import android.app.Notification ;
import android.app.NotificationManager ;
2019-02-15 12:17:49 +00:00
import android.app.PendingIntent ;
2019-01-22 18:25:45 +00:00
import android.app.Service ;
import android.content.Context ;
import android.content.Intent ;
import android.database.Cursor ;
import android.database.DatabaseUtils ;
import android.database.sqlite.SQLiteDatabase ;
2019-02-15 12:17:49 +00:00
import android.net.Uri ;
2019-01-22 18:25:45 +00:00
import android.os.IBinder ;
import android.support.v4.app.NotificationCompat ;
import android.util.Log ;
2020-06-02 05:59:16 +00:00
import com.google.common.base.Strings ;
2019-01-22 18:25:45 +00:00
import java.io.DataOutputStream ;
import java.io.File ;
import java.io.FileOutputStream ;
import java.io.PrintWriter ;
import java.security.NoSuchAlgorithmException ;
import java.security.SecureRandom ;
import java.security.spec.InvalidKeySpecException ;
2019-07-15 17:11:00 +00:00
import java.util.ArrayList ;
2019-01-22 18:25:45 +00:00
import java.util.Arrays ;
2019-07-15 17:11:00 +00:00
import java.util.Collections ;
2019-01-22 18:25:45 +00:00
import java.util.List ;
import java.util.concurrent.atomic.AtomicBoolean ;
import java.util.zip.GZIPOutputStream ;
import javax.crypto.Cipher ;
import javax.crypto.CipherOutputStream ;
import javax.crypto.SecretKeyFactory ;
import javax.crypto.spec.IvParameterSpec ;
import javax.crypto.spec.PBEKeySpec ;
import javax.crypto.spec.SecretKeySpec ;
import eu.siacs.conversations.Config ;
import eu.siacs.conversations.R ;
import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore ;
import eu.siacs.conversations.entities.Account ;
import eu.siacs.conversations.entities.Conversation ;
import eu.siacs.conversations.entities.Message ;
import eu.siacs.conversations.persistance.DatabaseBackend ;
import eu.siacs.conversations.persistance.FileBackend ;
import eu.siacs.conversations.utils.BackupFileHeader ;
import eu.siacs.conversations.utils.Compatibility ;
public class ExportBackupService extends Service {
public static final String KEYTYPE = " AES " ;
public static final String CIPHERMODE = " AES/GCM/NoPadding " ;
public static final String PROVIDER = " BC " ;
2019-07-15 17:11:00 +00:00
public static final String MIME_TYPE = " application/vnd.conversations.backup " ;
2019-01-22 18:25:45 +00:00
private static final int NOTIFICATION_ID = 19 ;
2019-01-23 10:20:36 +00:00
private static final int PAGE_SIZE = 20 ;
2019-01-22 18:25:45 +00:00
private static AtomicBoolean running = new AtomicBoolean ( false ) ;
private DatabaseBackend mDatabaseBackend ;
private List < Account > mAccounts ;
private NotificationManager notificationManager ;
2019-02-15 12:17:49 +00:00
private static List < Intent > getPossibleFileOpenIntents ( final Context context , final String path ) {
//http://www.openintents.org/action/android-intent-action-view/file-directory
//do not use 'vnd.android.document/directory' since this will trigger system file manager
Intent openIntent = new Intent ( Intent . ACTION_VIEW ) ;
openIntent . addCategory ( Intent . CATEGORY_DEFAULT ) ;
if ( Compatibility . runsAndTargetsTwentyFour ( context ) ) {
openIntent . setType ( " resource/folder " ) ;
} else {
2020-06-02 05:59:16 +00:00
openIntent . setDataAndType ( Uri . parse ( " file:// " + path ) , " resource/folder " ) ;
2019-02-15 12:17:49 +00:00
}
openIntent . putExtra ( " org.openintents.extra.ABSOLUTE_PATH " , path ) ;
Intent amazeIntent = new Intent ( Intent . ACTION_VIEW ) ;
amazeIntent . setDataAndType ( Uri . parse ( " com.amaze.filemanager: " + path ) , " resource/folder " ) ;
//will open a file manager at root and user can navigate themselves
Intent systemFallBack = new Intent ( Intent . ACTION_VIEW ) ;
systemFallBack . addCategory ( Intent . CATEGORY_DEFAULT ) ;
systemFallBack . setData ( Uri . parse ( " content://com.android.externalstorage.documents/root/primary " ) ) ;
return Arrays . asList ( openIntent , amazeIntent , systemFallBack ) ;
}
2020-06-02 05:59:16 +00:00
private static void accountExport ( final SQLiteDatabase db , final String uuid , final PrintWriter writer ) {
2019-01-23 10:20:36 +00:00
final StringBuilder builder = new StringBuilder ( ) ;
2019-01-22 18:25:45 +00:00
final Cursor accountCursor = db . query ( Account . TABLENAME , null , Account . UUID + " =? " , new String [ ] { uuid } , null , null , null ) ;
while ( accountCursor ! = null & & accountCursor . moveToNext ( ) ) {
builder . append ( " INSERT INTO " ) . append ( Account . TABLENAME ) . append ( " ( " ) ;
for ( int i = 0 ; i < accountCursor . getColumnCount ( ) ; + + i ) {
if ( i ! = 0 ) {
builder . append ( ',' ) ;
}
builder . append ( accountCursor . getColumnName ( i ) ) ;
}
builder . append ( " ) VALUES( " ) ;
for ( int i = 0 ; i < accountCursor . getColumnCount ( ) ; + + i ) {
if ( i ! = 0 ) {
builder . append ( ',' ) ;
}
final String value = accountCursor . getString ( i ) ;
if ( value = = null | | Account . ROSTERVERSION . equals ( accountCursor . getColumnName ( i ) ) ) {
builder . append ( " NULL " ) ;
} else if ( value . matches ( " \\ d+ " ) ) {
int intValue = Integer . parseInt ( value ) ;
if ( Account . OPTIONS . equals ( accountCursor . getColumnName ( i ) ) ) {
intValue | = 1 < < Account . OPTION_DISABLED ;
}
builder . append ( intValue ) ;
} else {
DatabaseUtils . appendEscapedSQLString ( builder , value ) ;
}
}
builder . append ( " ) " ) ;
builder . append ( ';' ) ;
builder . append ( '\n' ) ;
}
if ( accountCursor ! = null ) {
accountCursor . close ( ) ;
}
writer . append ( builder . toString ( ) ) ;
}
2019-01-23 10:20:36 +00:00
private static void simpleExport ( SQLiteDatabase db , String table , String column , String uuid , PrintWriter writer ) {
final Cursor cursor = db . query ( table , null , column + " =? " , new String [ ] { uuid } , null , null , null ) ;
while ( cursor ! = null & & cursor . moveToNext ( ) ) {
writer . write ( cursorToString ( table , cursor , PAGE_SIZE ) ) ;
}
if ( cursor ! = null ) {
cursor . close ( ) ;
}
}
2020-06-02 05:59:16 +00:00
public static byte [ ] getKey ( final String password , final byte [ ] salt ) throws InvalidKeySpecException {
final SecretKeyFactory factory ;
2019-01-23 10:20:36 +00:00
try {
2020-06-02 05:59:16 +00:00
factory = SecretKeyFactory . getInstance ( " PBKDF2WithHmacSHA1 " ) ;
} catch ( NoSuchAlgorithmException e ) {
throw new IllegalStateException ( e ) ;
2019-01-23 10:20:36 +00:00
}
2020-06-02 05:59:16 +00:00
return factory . generateSecret ( new PBEKeySpec ( password . toCharArray ( ) , salt , 1024 , 128 ) ) . getEncoded ( ) ;
2019-01-23 10:20:36 +00:00
}
2020-06-02 05:59:16 +00:00
private static String cursorToString ( final String table , final Cursor cursor , final int max ) {
return cursorToString ( table , cursor , max , false ) ;
2019-01-23 10:20:36 +00:00
}
2020-06-02 05:59:16 +00:00
private static String cursorToString ( final String table , final Cursor cursor , int max , boolean ignore ) {
final boolean identities = SQLiteAxolotlStore . IDENTITIES_TABLENAME . equals ( table ) ;
2019-01-23 10:20:36 +00:00
StringBuilder builder = new StringBuilder ( ) ;
builder . append ( " INSERT " ) ;
if ( ignore ) {
builder . append ( " OR IGNORE " ) ;
}
2020-06-02 05:59:16 +00:00
builder . append ( " INTO " ) . append ( table ) . append ( " ( " ) ;
2019-03-07 08:08:32 +00:00
int skipColumn = - 1 ;
2019-01-23 10:20:36 +00:00
for ( int i = 0 ; i < cursor . getColumnCount ( ) ; + + i ) {
2019-03-07 08:08:32 +00:00
final String name = cursor . getColumnName ( i ) ;
if ( identities & & SQLiteAxolotlStore . TRUSTED . equals ( name ) ) {
skipColumn = i ;
continue ;
}
2019-01-23 10:20:36 +00:00
if ( i ! = 0 ) {
builder . append ( ',' ) ;
}
2019-03-07 08:08:32 +00:00
builder . append ( name ) ;
2019-01-23 10:20:36 +00:00
}
builder . append ( " ) VALUES " ) ;
for ( int i = 0 ; i < max ; + + i ) {
if ( i ! = 0 ) {
builder . append ( ',' ) ;
}
2019-03-07 08:08:32 +00:00
appendValues ( cursor , builder , skipColumn ) ;
2019-01-23 10:20:36 +00:00
if ( i < max - 1 & & ! cursor . moveToNext ( ) ) {
break ;
}
}
builder . append ( ';' ) ;
builder . append ( '\n' ) ;
return builder . toString ( ) ;
}
2019-03-07 08:08:32 +00:00
private static void appendValues ( final Cursor cursor , final StringBuilder builder , final int skipColumn ) {
2019-01-23 10:20:36 +00:00
builder . append ( " ( " ) ;
for ( int i = 0 ; i < cursor . getColumnCount ( ) ; + + i ) {
2019-03-07 08:08:32 +00:00
if ( i = = skipColumn ) {
continue ;
}
2019-01-23 10:20:36 +00:00
if ( i ! = 0 ) {
builder . append ( ',' ) ;
}
final String value = cursor . getString ( i ) ;
if ( value = = null ) {
builder . append ( " NULL " ) ;
2019-04-28 10:46:49 +00:00
} else if ( value . matches ( " [0-9]+ " ) ) {
2019-01-23 10:20:36 +00:00
builder . append ( value ) ;
} else {
DatabaseUtils . appendEscapedSQLString ( builder , value ) ;
}
}
builder . append ( " ) " ) ;
}
@Override
public void onCreate ( ) {
mDatabaseBackend = DatabaseBackend . getInstance ( getBaseContext ( ) ) ;
mAccounts = mDatabaseBackend . getAccounts ( ) ;
notificationManager = ( NotificationManager ) getSystemService ( Context . NOTIFICATION_SERVICE ) ;
}
@Override
public int onStartCommand ( Intent intent , int flags , int startId ) {
if ( running . compareAndSet ( false , true ) ) {
new Thread ( ( ) - > {
2019-07-15 17:11:00 +00:00
boolean success ;
List < File > files ;
try {
files = export ( ) ;
success = true ;
2020-06-02 05:59:16 +00:00
} catch ( final Exception e ) {
Log . d ( Config . LOGTAG , " unable to create backup " , e ) ;
2019-07-15 17:11:00 +00:00
success = false ;
files = Collections . emptyList ( ) ;
}
2019-01-23 10:20:36 +00:00
stopForeground ( true ) ;
running . set ( false ) ;
2019-02-15 12:17:49 +00:00
if ( success ) {
2019-07-15 17:11:00 +00:00
notifySuccess ( files ) ;
2019-02-15 12:17:49 +00:00
}
2019-01-23 10:20:36 +00:00
stopSelf ( ) ;
} ) . start ( ) ;
return START_STICKY ;
2020-06-02 05:59:16 +00:00
} else {
Log . d ( Config . LOGTAG , " ExportBackupService. ignoring start command because already running " ) ;
2019-01-23 10:20:36 +00:00
}
return START_NOT_STICKY ;
}
2019-01-22 18:25:45 +00:00
private void messageExport ( SQLiteDatabase db , String uuid , PrintWriter writer , Progress progress ) {
Cursor cursor = db . rawQuery ( " select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=? " , new String [ ] { uuid } ) ;
int size = cursor ! = null ? cursor . getCount ( ) : 0 ;
2020-06-02 05:59:16 +00:00
Log . d ( Config . LOGTAG , " exporting " + size + " messages for account " + uuid ) ;
2019-01-22 18:25:45 +00:00
int i = 0 ;
int p = 0 ;
while ( cursor ! = null & & cursor . moveToNext ( ) ) {
2019-01-23 10:20:36 +00:00
writer . write ( cursorToString ( Message . TABLENAME , cursor , PAGE_SIZE , false ) ) ;
if ( i + PAGE_SIZE > size ) {
2019-01-22 18:25:45 +00:00
i = size ;
} else {
2019-01-23 10:20:36 +00:00
i + = PAGE_SIZE ;
2019-01-22 18:25:45 +00:00
}
final int percentage = i * 100 / size ;
if ( p < percentage ) {
p = percentage ;
2019-01-23 10:20:36 +00:00
notificationManager . notify ( NOTIFICATION_ID , progress . build ( p ) ) ;
2019-01-22 18:25:45 +00:00
}
}
if ( cursor ! = null ) {
cursor . close ( ) ;
}
}
2019-07-15 17:11:00 +00:00
private List < File > export ( ) throws Exception {
2019-01-22 18:25:45 +00:00
NotificationCompat . Builder mBuilder = new NotificationCompat . Builder ( getBaseContext ( ) , " backup " ) ;
mBuilder . setContentTitle ( getString ( R . string . notification_create_backup_title ) )
. setSmallIcon ( R . drawable . ic_archive_white_24dp )
. setProgress ( 1 , 0 , false ) ;
startForeground ( NOTIFICATION_ID , mBuilder . build ( ) ) ;
2019-07-15 17:11:00 +00:00
int count = 0 ;
final int max = this . mAccounts . size ( ) ;
final SecureRandom secureRandom = new SecureRandom ( ) ;
final List < File > files = new ArrayList < > ( ) ;
2020-06-02 05:59:16 +00:00
Log . d ( Config . LOGTAG , " starting backup for " + max + " accounts " ) ;
for ( final Account account : this . mAccounts ) {
final String password = account . getPassword ( ) ;
if ( Strings . nullToEmpty ( password ) . trim ( ) . isEmpty ( ) ) {
Log . d ( Config . LOGTAG , String . format ( " skipping backup for %s because password is empty. unable to encrypt " , account . getJid ( ) . asBareJid ( ) ) ) ;
continue ;
}
Log . d ( Config . LOGTAG , String . format ( " exporting data for account %s (%s) " , account . getJid ( ) . asBareJid ( ) , account . getUuid ( ) ) ) ;
2019-07-15 17:11:00 +00:00
final byte [ ] IV = new byte [ 12 ] ;
final byte [ ] salt = new byte [ 16 ] ;
secureRandom . nextBytes ( IV ) ;
secureRandom . nextBytes ( salt ) ;
final BackupFileHeader backupFileHeader = new BackupFileHeader ( getString ( R . string . app_name ) , account . getJid ( ) , System . currentTimeMillis ( ) , IV , salt ) ;
final Progress progress = new Progress ( mBuilder , max , count ) ;
final File file = new File ( FileBackend . getBackupDirectory ( this ) + account . getJid ( ) . asBareJid ( ) . toEscapedString ( ) + " .ceb " ) ;
files . add ( file ) ;
2020-06-02 05:59:16 +00:00
final File directory = file . getParentFile ( ) ;
if ( directory ! = null & & directory . mkdirs ( ) ) {
Log . d ( Config . LOGTAG , " created backup directory " + directory . getAbsolutePath ( ) ) ;
2019-07-15 17:11:00 +00:00
}
final FileOutputStream fileOutputStream = new FileOutputStream ( file ) ;
final DataOutputStream dataOutputStream = new DataOutputStream ( fileOutputStream ) ;
backupFileHeader . write ( dataOutputStream ) ;
dataOutputStream . flush ( ) ;
final Cipher cipher = Compatibility . twentyEight ( ) ? Cipher . getInstance ( CIPHERMODE ) : Cipher . getInstance ( CIPHERMODE , PROVIDER ) ;
2020-06-02 05:59:16 +00:00
final byte [ ] key = getKey ( password , salt ) ;
2019-07-15 17:11:00 +00:00
SecretKeySpec keySpec = new SecretKeySpec ( key , KEYTYPE ) ;
IvParameterSpec ivSpec = new IvParameterSpec ( IV ) ;
cipher . init ( Cipher . ENCRYPT_MODE , keySpec , ivSpec ) ;
CipherOutputStream cipherOutputStream = new CipherOutputStream ( fileOutputStream , cipher ) ;
GZIPOutputStream gzipOutputStream = new GZIPOutputStream ( cipherOutputStream ) ;
PrintWriter writer = new PrintWriter ( gzipOutputStream ) ;
SQLiteDatabase db = this . mDatabaseBackend . getReadableDatabase ( ) ;
final String uuid = account . getUuid ( ) ;
accountExport ( db , uuid , writer ) ;
simpleExport ( db , Conversation . TABLENAME , Conversation . ACCOUNT , uuid , writer ) ;
messageExport ( db , uuid , writer , progress ) ;
for ( String table : Arrays . asList ( SQLiteAxolotlStore . PREKEY_TABLENAME , SQLiteAxolotlStore . SIGNED_PREKEY_TABLENAME , SQLiteAxolotlStore . SESSION_TABLENAME , SQLiteAxolotlStore . IDENTITIES_TABLENAME ) ) {
simpleExport ( db , table , SQLiteAxolotlStore . ACCOUNT , uuid , writer ) ;
2019-01-22 18:25:45 +00:00
}
2019-07-15 17:11:00 +00:00
writer . flush ( ) ;
writer . close ( ) ;
Log . d ( Config . LOGTAG , " written backup to " + file . getAbsoluteFile ( ) ) ;
count + + ;
2019-01-22 18:25:45 +00:00
}
2019-07-15 17:11:00 +00:00
return files ;
2019-01-22 18:25:45 +00:00
}
2020-06-02 05:59:16 +00:00
private void notifySuccess ( final List < File > files ) {
2019-02-15 12:17:49 +00:00
final String path = FileBackend . getBackupDirectory ( this ) ;
2019-07-15 17:11:00 +00:00
PendingIntent openFolderIntent = null ;
2019-02-15 12:17:49 +00:00
for ( Intent intent : getPossibleFileOpenIntents ( this , path ) ) {
if ( intent . resolveActivityInfo ( getPackageManager ( ) , 0 ) ! = null ) {
2019-07-15 17:11:00 +00:00
openFolderIntent = PendingIntent . getActivity ( this , 189 , intent , PendingIntent . FLAG_UPDATE_CURRENT ) ;
2019-02-15 12:17:49 +00:00
break ;
}
}
2019-07-15 17:11:00 +00:00
PendingIntent shareFilesIntent = null ;
if ( files . size ( ) > 0 ) {
final Intent intent = new Intent ( Intent . ACTION_SEND_MULTIPLE ) ;
ArrayList < Uri > uris = new ArrayList < > ( ) ;
2020-06-02 05:59:16 +00:00
for ( File file : files ) {
2019-07-15 17:11:00 +00:00
uris . add ( FileBackend . getUriForFile ( this , file ) ) ;
}
intent . putParcelableArrayListExtra ( Intent . EXTRA_STREAM , uris ) ;
intent . addFlags ( Intent . FLAG_GRANT_READ_URI_PERMISSION ) ;
intent . setType ( MIME_TYPE ) ;
final Intent chooser = Intent . createChooser ( intent , getString ( R . string . share_backup_files ) ) ;
2020-06-02 05:59:16 +00:00
shareFilesIntent = PendingIntent . getActivity ( this , 190 , chooser , PendingIntent . FLAG_UPDATE_CURRENT ) ;
2019-07-15 17:11:00 +00:00
}
2019-02-15 12:17:49 +00:00
NotificationCompat . Builder mBuilder = new NotificationCompat . Builder ( getBaseContext ( ) , " backup " ) ;
mBuilder . setContentTitle ( getString ( R . string . notification_backup_created_title ) )
. setContentText ( getString ( R . string . notification_backup_created_subtitle , path ) )
. setStyle ( new NotificationCompat . BigTextStyle ( ) . bigText ( getString ( R . string . notification_backup_created_subtitle , FileBackend . getBackupDirectory ( this ) ) ) )
. setAutoCancel ( true )
2019-07-15 17:11:00 +00:00
. setContentIntent ( openFolderIntent )
2019-02-15 12:17:49 +00:00
. setSmallIcon ( R . drawable . ic_archive_white_24dp ) ;
2019-07-15 17:11:00 +00:00
if ( shareFilesIntent ! = null ) {
mBuilder . addAction ( R . drawable . ic_share_white_24dp , getString ( R . string . share_backup_files ) , shareFilesIntent ) ;
}
2019-02-15 12:17:49 +00:00
notificationManager . notify ( NOTIFICATION_ID , mBuilder . build ( ) ) ;
}
2019-01-22 18:25:45 +00:00
@Override
public IBinder onBind ( Intent intent ) {
return null ;
}
2020-06-02 05:59:16 +00:00
private static class Progress {
2019-01-22 18:25:45 +00:00
private final NotificationCompat . Builder builder ;
private final int max ;
private final int count ;
private Progress ( NotificationCompat . Builder builder , int max , int count ) {
this . builder = builder ;
this . max = max ;
this . count = count ;
}
private Notification build ( int percentage ) {
2019-01-23 10:20:36 +00:00
builder . setProgress ( max * 100 , count * 100 + percentage , false ) ;
2019-01-22 18:25:45 +00:00
return builder . build ( ) ;
}
}
2019-04-28 10:46:49 +00:00
}