Pull to refresh

CIFS в Android, или как я файлы с разбитого телефона доставал

Development for Android *
Tutorial
Так получилось, что я разбил экран у своего любимого Nexus 4. Первой мыслью было «Чёрт! Теперь я буду как один из этих нищебродов, с разбитым экраном!». Но, видимо, создатели Nexus 4 были ярыми противниками нищебродства, так как вместе с разбитым экраном, полностью отказал сенсорный экран. В общем, ничего страшного, отнести телефон в ремонт и все. Однако, на телефоне были файлы, которые нужны были мне прямо сейчас, а не через пару недель. Но вот получить их представлялось возможным только при разблокированном экране, телефон требовал ввод “супер секретного” жеста и категорически не хотел работать как внешний накопитель.



Немного покопавшись с adb я плюнул на попытки разблокировать экран через консоль. Все советы по взлому экрана блокировки требовали наличие рута, а мой телефон не из этих. Решено было действовать изнутри. Выбор пал на библиотеку JCIFS, так как раньше мне уже приходилось работать с ней и проблем в её использовании не возникало.

Нужно было написать приложение, которое бы самостоятельно скопировало файлы с телефона на расшаренную по Wi-Fi папку. Обязательные условия для такого трюка: включенная отладка через USB и компьютер которому телефон уже выдал разрешение на отладку, а также наличие Wi-Fi сети, к которой телефон подключится как только ее увидит (у меня это домашний Wi-Fi).

Подготовительные работы


Создадим проект с одной Activity. Хоть она и не увидит белого света из-за экрана блокировки, но для запуска сервиса, который сделает основную работу, она будет нужна.
MainActivity.java
public class MainActivity extends Activity {

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       startService(new Intent(this, SynchronizeService.class));
   }
}


Копированием файлов будет заниматься отдельный сервис. Так как Activity не видна, на ее жизнеспособность расчитывать не стоит, а вот сервис, запущенный в Foreground, прекрасно справится с этой задачей.
SynchronizeService.java
public class SynchronizeService extends Service {
    private static final int FOREGROUND_NOTIFY_ID = 1;

    @Override
    public void onCreate() {
        super.onCreate();
        final NotificationCompat.Builder builder = new NotificationCompat.Builder(this)
                .setSmallIcon(R.mipmap.ic_launcher)
                .setContentTitle(getString(R.string.app_name))
                .setContentText(getString(R.string.synchronize_service_message))
                .setContentIntent(getDummyContentIntent())
                .setColor(Color.BLUE)
                .setProgress(1000, 0, true);
        startForeground(FOREGROUND_NOTIFY_ID, builder.build());

        // Это поможет удерживать CPU в бодром состоянии
        PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE);
        final WakeLock wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SynchronizeWakelockTag");
        wakeLock.acquire();
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        return START_NOT_STICKY;
    }

    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }
}

Перед тем, как двигаться дальше, добавим зависимость, файл build.gradle, которая добавит в проект библиотеку JCIFS.
dependencies {
   ...
   compile 'jcifs:jcifs:1.3.17'
}

Также нужно добавить кое-какие разрешения в манифест и не забыть написать там про наш сервис. В конечном счете, AndroidManifest.xml у меня выглядел вот так.
AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
   package="ru.kamisempai.wifisynchronizer">

   // Нужно для удерживания телефона от сна.
   <uses-permission android:name="android.permission.WAKE_LOCK"/>

   // Потребуется для работы с сетью.
   <uses-permission android:name="android.permission.INTERNET"/>

   // Для чтения с SD карты.
   <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

   <application
       android:label="@string/app_name"
       android:icon="@mipmap/ic_launcher">

        <activity android:name=".activities.MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

       <service android:name=".services.SynchronizeService"/>

   </application>
</manifest>

Копирование файлов


Итак, все приготовления закончены. Теперь, если запустить приложение, в списке нотификаций появится сообщения сервиса (начиная с Android 5, можно настроить показ сообщений на экране блокировки. Если версия Android меньше, вы этого сообщения не увидите), а это значит, приложение работает как надо и можно приступать к самому вкусному — перекачке файлов.

Дабы не совершать сетевые операции в главном потоке, вынесем все это дело в AsyncTask.
public class CopyFilesToSharedFolderTask extends AsyncTask<Void, Double, String> {

    private final File mFolderToCopy;
    private final String mSharedFolderUrl;
    private final NtlmPasswordAuthentication mAuth;

    private FileFilter mFileFilter;

    public CopyFilesToSharedFolderTask(File folderToCopy, String sharedFolderUrl, String user, String password, FileFilter fileFilter) {
        super();
        mFolderToCopy = folderToCopy;       // Папка, которая должна быть скопирована.
        mSharedFolderUrl = sharedFolderUrl; // Url к сетевой папке, в которую будет скопированы файлы с телефона.
        mAuth = (user != null && password != null)
                ? new NtlmPasswordAuthentication(user + ":" + password)
                : NtlmPasswordAuthentication.ANONYMOUS;
        mFileFilter = fileFilter;
    }
}
Особое внимание стоит обратить на параметры user и password. Это логин и пароль к сетевой папке, которые будут использованы для создания NtlmPasswordAuthentication. Если для доступа к папке пароль не требуется, в качестве аутентификации нужно использовать NtlmPasswordAuthentication.ANONYMOUS. Выглядит просто, однако, аутентификация это самая большая проблема, с которой вы можете столкнуться при работе с сетевыми папками. Обычно, большинство проблем скрываются в не правильной настройке политики безопасности на компьютере. Самый лучший способ проверить правильность настроек — это попробовать открыть сетевую папку на телефоне, через любой другой файловый менеджер, поддерживающий работу через сеть.

SmbFile — это файл для работы с сетевыми файлами. Удивительно, но в JCIFS очень легко работать с файлами. Вы не почувствуете практически никакой разницы между SmbFile и обычным File. Единственное, что бросается в глаза, это наличие управляемых исключений практически во всех методах класса. А еще для создания объекта SmbFile потребуется данные для аутентификации, которые мы создали ранее.
private double mMaxProgress;
private double mProgress;

...

@Override
protected String doInBackground(Void... voids) {
    mMaxProgress = getFilesSize(mFolderToCopy);
    mProgress = 0;
    publishProgress(0d);

    try {
        SmbFile sharedFolder = new SmbFile(mSharedFolderUrl, mAuth);
        if (sharedFolder.exists() && sharedFolder.isDirectory()) {
            copyFiles(mFolderToCopy, sharedFolder);
        }
    } catch (MalformedURLException e) {
        return "Invalid URL.";
    } catch (IOException e) {
        e.printStackTrace();
        return e.getMessage();
    }

    return null;
}
Метод doInBackground возвращает сообщение об ошибке. Если возвращается null, значит все прошло гладко и без ошибок.

Файлов может быть много… Нет, не так. Их может быть ОЧЕНЬ много! Поэтому, показывать прогресс — жизненно необходимая функция. Рекурсивный метод getFilesSize вычисляет общий объем файлов, который потребуется для вычисления общего прогресса.
private double getFilesSize(File file) {
    if (!checkFilter(file))
        return 0;

    if (file.isDirectory()) {
        int size = 0;
        File[] filesList = file.listFiles();
        for (File innerFile : filesList)
            size += getFilesSize(innerFile);
        return size;
    }

    return (double) file.length();
}

private boolean checkFilter(File file) {
    return mFileFilter == null || mFileFilter.accept(file);
}
Переданный в конструктор фильтр помогает исключить ненужные файлы и папки. Например, можно исключить все папки начинающиеся с точки или добавить в черный список папку «Android».

Как я уже говорил ранее, работа с SmbFile ничем не отличается от работы с обычным файлом, поэтому, процесс переноса данных с телефона на компьютер не отличается оригинальностью. Я даже спрячу этот код под спойлер, дабы не засорять статью еще большим количеством очевидного кода.
Методы copyFiles и copySingleFile
private static final String LOG_TAG = "WiFiSynchronizer";

private void copyFiles(File fileToCopy, SmbFile sharedFolder) throws IOException {
    if (!checkFilter(fileToCopy))
        return; // Если файл или папка не проходят фильтр, не копируем ее.

    if (fileToCopy.exists()) {
        if (fileToCopy.isDirectory()) {
            File[] filesList = fileToCopy.listFiles();

            // При создании директории в конце ставится "/".
            SmbFile newSharedFolder = new SmbFile(sharedFolder, fileToCopy.getName() + "/");
            if (!newSharedFolder.exists()) {
                newSharedFolder.mkdir();
                Log.d(LOG_TAG, "Folder created:" + newSharedFolder.getPath());
            }
            else
                Log.d(LOG_TAG, "Folder already exist:" + newSharedFolder.getPath());
            for (File file : filesList)
                copyFiles(file, newSharedFolder); // Рекурсивный вызов
        } else {
            SmbFile newSharedFile = new SmbFile(sharedFolder, fileToCopy.getName());

            // Если файл уже создан, не будем его копировать.
            // Конечно, в другой ситуации, стоило бы добавить проверку по хэшу, но в моем случае это будет лишним.
            if (!newSharedFile.exists()) {
                copySingleFile(fileToCopy, newSharedFile);
                Log.d(LOG_TAG, "File copied:" + newSharedFile.getPath());
            }
            else
                Log.d(LOG_TAG, "File already exist:" + newSharedFile.getPath());

            // Обновляем прогресс.
            mProgress += (double) fileToCopy.length();
            publishProgress(mProgress / mMaxProgress * 100d);
        }
    }
}

// Ничем не примечательный метод по копированию файлов. 
private void copySingleFile(File file, SmbFile sharedFile) throws IOException {
    IOException exception = null;
    InputStream inputStream = null;
    OutputStream outputStream = null;
    try {
        outputStream = new SmbFileOutputStream(sharedFile);
        inputStream = new FileInputStream(file);

        byte[] bytesBuffer = new byte[1024];
        int bytesRead;
        while ((bytesRead = inputStream.read(bytesBuffer)) > 0) {
            outputStream.write(bytesBuffer, 0, bytesRead);
        }
    } catch (IOException e) {
        exception = e;
    } finally {
        if (inputStream != null)
            try {
                inputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        if (outputStream != null)
            try {
                outputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
    }
    if (exception != null)
        throw exception;
}
Код очевиден, однако в нем есть один, совсем не очевидный момент — это добавление символа "/" к концу имени папки при создании нового SmbFile. Дело в том, что JCIFS воспринимает все файлы, которые не заканчиваются на символ "/" только как файл, не как директорию. Поэтому, если Url сетевой папки будет выглядеть так: «file://MY-PC/shared/some_foldel», возникнут казусы при создании новой папки в папке «some_foldel». А именно, «some_foldel» будет отброшено, и новая папка будет иметь Url: «file://MY-PC/shared/new_folder», вместо ожидаемого «file://MY-PC/shared/some_foldel/new_folder». При этом, для таких папок, методы isDirectory, mkdir или listFiles будут работать корректно.

Почти готово. Теперь запустим выполнение этой задачи в onCreate сервиса.
private static final int FOREGROUND_NOTIFY_ID = 1;
private static final int MESSAGE_NOTIFY_ID = 2;

private static final String SHARED_FOLDER_URL = "file://192.168.0.5/shared/";
...

final File folderToCopy = getFolderToCopy();
CopyFilesToSharedFolderTask task = new CopyFilesToSharedFolderTask(folderToCopy, SHARED_FOLDER_URL, null, null, null) {
    @Override
    protected void onProgressUpdate(Double... values) {
        builder.setProgress(100, values[0].intValue(), false)
            .setContentText(String.format("%s %.3f", getString(R.string.synchronize_service_progress), values[0]) + "%");
        mNotifyManager.notify(FOREGROUND_NOTIFY_ID, builder.build());
    }

    @Override
    protected void onPostExecute(String errorMessage) {
        stopForeground(true);
        if (errorMessage == null)
            showNotification(getString(R.string.synchronize_service_success), Color.GREEN);
        else
            showNotification(errorMessage, Color.RED);
        stopSelf();
        wakeLock.release(); // Не забываем освободить wakeLock
    }

    @Override
    protected void onCancelled(String errorMessage) {
        // Этот код никогда не выполнится. Но мало ли, вдруг мне захочется что-то поменять.
        // Тогда сервис никогда не остановится при закрытии таска.
        stopSelf();
        wakeLock.release();
    }
};
task.execute();
В моем случае логин и пароль не требуются, фильтр я тоже указывать не стал. Метод onProgressUpdate переопределен для показа состояния прогресса, а onPostExecute показывает сообщение об окончании загрузки, либо о возникновении ошибки, после чего завершает работу сервиса.

Запустим приложение. Появилось сообщение от запущенного сервиса. Пока идет вычисление общего объема файлов, показывается неопределенное состояние прогресса. Но вот индикатор показывает 0%, после чего полоска постепенно, маленькими, чуть заметными шажками, начинает двигаться к 100%.


Когда работа была завершена, на экране высветилось сообщение об удачном результате, и у меня на компьютере были все необходимые файлы, ранее заточённые на разбитом телефоне.

Неожиданные выводы


То, что было нужно я получил. В самое время заварить чайку, развалиться на диване и включить какой-нибудь сериальчик. Но, постойте! Несмотря на то, что телефон был мой и доступ к файлам на нем не противоречит российскому законодательству, я достал их без использования пароля! При этом, на телефоне не стоял Root. Это значит, что при одном только включенном режиме отладки не трудно получить доступ к содержимому «SD карты», даже не зная пароля. Спасает только то, что единственным толстым местом в защите от взлома является необходимость использования компьютера который уже имеет права на отладку.

Представленная, совсем не давно, новая версия Android, возможно, закроет эту дыру, так как для доступа к необходимым разрешениям, потребуется подтверждение пользователя, что невозможно при заблокированном экране. А пока, Android разработчик, будь на стороже, если не хочешь, чтобы твои ню фоточки увидел кто-то другой. И помни, разрешая любому компьютеру отладку по USB ты создаешь еще одну лазейку для взлома собственного телефона.

Спасибо за внимание. Буду рад увидеть ваши мысли в комментариях.
Исходный код можно найти по следующей ссылке: github.com/KamiSempai/WiFiFolderSynchronizer

UPD: Как я и предполагал, есть более простое решение моей проблемы. А именно воспользоваться командой adb pull. К сожалению редкое использование adb и узкий взгляд на проблему не позволили мне к нему прийти. Я пытался разблокировать экран, а не скачать файлы.
Спасибо EvgenT за удачное замечание.
Tags: androidandroid developmentjcifscifssmbвелосипеднестандартные решения
Hubs: Development for Android
Total votes 20: ↑17 and ↓3 +14
Comments 27
Comments Comments 27

Popular right now