четверг, 30 января 2014 г.

Пишем свой файловый менеджер для Android, #1

Решил попробовать написать простенький файловый менеджер, используя при этом модуль «System.IOUtils», чтобы умел перемещаться по папкам, открывать/удалять файлы. (Как вы могли заметить, в заголовке есть номер части, это значит, что будут ещё статьи, где я буду "допиливать" это приложение)
Описывать весь модуль «System.IOUtils» я не буду, т.к. в интернете и официальной справке вы можете найти достаточно подробное описание этого модуля и его классов, методов.
И так, поехали.

Статья №2 (Пишем свой файловый менеджер для Android, #2)

Upd (21.04.14). Проверил код на Delphi XE6 и добавил исходники для новой версии IDE.


Определяем минимальный функционал приложения:
1) Чтение, перемещение по директориям на устройстве (начинать будем с корня устройства, т.е. «/»)
2) Определять тип пути (папка или файл)
3) Удаление директорий и файлов
4) Открывать файлы с использованием Intent (намерения) в Android
5) Обязательно обрабатывать нажатие на хардварную кнопку «назад», как возврат в предыдущую директорию (до корня устройства)

Структура проекта:

Принцип работы:

Чтение, перемещение по директориям на устройстве
1) TForm1.FormCreate - При запуске приложения будем читать корневую директорию устройства.
2) TForm1.ListBox1ItemClick – При клике по итему, читаем выбранную директорию. (т.е. идём глубже по дереву)
3) TForm1.SpeedButton1Click (и TForm1.FormKeyUp) – Кнопка назад, читаем предыдущую директорию.

Для того чтобы использовать модуль «System.IOUtils», его нужно подключить в Uses.

Т.к. во всех трёх процедурах будет почти идентичный код, вынесем его в отдельную процедуру, я назвал её так «TForm1.TotalWork», а также для удобства создал процедуру для добавления пунктов в ListBox, это «TForm1.AddListItem». Функция «CompareLowerStr» - это условие для сортировки массивов. Ещё нам понадобится глобальная переменная «path», которая будет содержать текущий путь.

Определять тип пути (папка или файл)
Тут всё получилось не очень, но, в конце концов, я сделал всё красиво (как мне кажется…) :).
Я не обнаружил (в модуле System.IOUtils) методов для определения типа (файл или папка) входящего пути, решил попробовать написать решение сам. Прочитал в справке про то, что можно получать атрибуты папки/файла и подумал, что всё классно. Нужно всего-то реагировать на «faDirectory» и «faNormal». Чтобы запросить атрибуты папки используем «TDirectory.GetAttributes» и для файла «TFile.GetAttributes». Далее используя условный оператор, можно было бы определить тип, но не тут-то было… Почему-то, если выбрана не папка, а файл, то выскакивала табличка с ошибкой и дальше ни чего не происходило. Сейчас даже кода не осталось, чтобы показать вам, да и писал я уже ночью, примерно в 2-3 часа, так что может и сам чего накосячил, в итоге я отказался от одного только условного оператора и использовал «try except end;». Что из этого вышло(примерно), смотрите ниже.
Код:
uses
  System.IOUtils;

var
  Attrs: TFileAttributes; // Атрибуты папки или файла
  LItem: string; // Выбранная папка или файл для удаления

begin
  LItem := ListBox1.ListItems[i].ItemData.Detail

  try
    Attrs := TDirectory.GetAttributes(LItem, False);

    if TFileAttribute.faDirectory in Attrs then
    begin
      ...
    end;
  except
    Attrs := TFile.GetAttributes(LItem, False);

    if TFileAttribute.faNormal in Attrs then
    begin
      ...
    end;
  end;

Решение получилось совсем некрасивое и "малость" корявое. Поэтому мне приснилось другое решение. :)
Зачем каждый раз читать атрибуты файлов и папок, чтобы определить тип, если можно сделать это один раз. Т.к. я получаю отдельно список папок (GetDirectories) и список файлов (GetFiles), после чего сортирую и добавляю в ListBox, то мне уже заранее известно, где будут папки, а где файлы, поэтому при добавлении в ListBox я сразу записываю тип сюда «LItem.TagString := itype;». В дальнейшем просто считываю значение и всё.

Удаление директорий и файлов
Чтобы удалить папку или файл, нужно знать тип. В предыдущем шаге, я описал, как получаю тип выбранного пункта в ListBox’е. Не забываем проверять выбранные папки и файлы на существование при помощи TDirectory.Exists(path) и TFile.Exists(path). Удаляем при помощи TDirectory.Delete(path, true) и TFile.Delete(path).

Открывать файлы с использованием Intent (намерения) в Android
Объяснять, что такое Intent я не буду, позволю себе привести ссылку (Delphi XE5: использование Intent (намерения) в Android) на статью с описанием. Я использовал один из множества вариантов использования Intent’ов (намерений). Единственное, о чём забывают упомянуть в подобных статьях, как автоматически определить MIME-type (Android API: http://developer.android.com/reference/android/webkit/MimeTypeMap.html).

Код для определения MIME-type:
uses
  Androidapi.JNI.JavaTypes, Androidapi.JNI.Webkit;

var
  ExtFile: string;
  mime: JMimeTypeMap;
  ExtToMime: JString;

begin
  //Определяем расширение файла и его mime тип
  ExtFile := AnsiLowerCase(StringReplace(TPath.GetExtension(path), '.', '',[]));
  mime := TJMimeTypeMap.JavaClass.getSingleton();
  ExtToMime := mime.getMimeTypeFromExtension(StringToJString(ExtFile));

Код приложения (Delphi XE5 Update 2):
unit Unit1;

interface

uses
  System.SysUtils, System.Types, System.UITypes, System.Classes, System.Variants,
  FMX.Types, FMX.Controls, FMX.Forms, FMX.Graphics, FMX.Dialogs,
  FMX.ListBox, FMX.Layouts, FMX.StdCtrls;

type
  TForm1 = class(TForm)
    ListBox1: TListBox;
    Label1: TLabel;
    ToolBar1: TToolBar;
    SpeedButton1: TSpeedButton;
    SpeedButton2: TSpeedButton;
    procedure FormCreate(Sender: TObject);
    procedure ListBox1ItemClick(const Sender: TCustomListBox;
      const Item: TListBoxItem);
    procedure SpeedButton1Click(Sender: TObject);
    procedure FormKeyUp(Sender: TObject; var Key: Word; var KeyChar: Char;
      Shift: TShiftState);
    procedure ListBox1ChangeCheck(Sender: TObject);
    procedure SpeedButton2Click(Sender: TObject);
  private
    procedure AddListItem(list: array of string; itype: string);
    procedure TotalWork(path_tr: string; clear: boolean);
    { Private declarations }
  public
    { Public declarations }
  end;

var
  Form1: TForm1;

implementation

{$R *.fmx}

uses
  System.IOUtils, System.Generics.Collections, Generics.Defaults,
  FMX.Helpers.Android, Androidapi.JNI.JavaTypes,
  Androidapi.JNI.GraphicsContentViewText, Androidapi.JNI.Webkit;

var
  path: string; // Здесь будем хранить путь
  ItemsCheck: array of string; // Массив с помеченными пунктами

function CompareLowerStr(const Left, Right: string): Integer;
begin
  Result := CompareStr(AnsiLowerCase(Left), AnsiLowerCase(Right));
end;

{Процедура для вставки массивов в ListBox}
procedure TForm1.AddListItem(list: array of string; itype: string);
var
  c: integer;
  LItem: TListBoxItem;
begin

  ListBox1.BeginUpdate;

  for c := 0 to Length(list) - 1 do
  begin
    LItem := TListBoxItem.Create(ListBox1);
    LItem.ItemData.Text := ExtractFileName(list[c]);
    LItem.ItemData.Detail := list[c]; // Помещаем полный путь в Detail
    LItem.TagString := itype;
    ListBox1.AddObject(LItem);
  end;

  ListBox1.EndUpdate;

end;

{Загружаем список файлов в корне устройства}
procedure TForm1.FormCreate(Sender: TObject);
begin
  // Корень устройства
  path := '/';

  TotalWork(path, False);
end;

{Обрабатываем кнопку Hardware Back}
procedure TForm1.FormKeyUp(Sender: TObject; var Key: Word; var KeyChar: Char;
  Shift: TShiftState);
begin
  if Key = vkHardwareBack then
  begin
    if path <> '/' then
    begin
      SpeedButton1Click(Self);
      Key := 0;
    end;
  end;
end;

{Пункт пометили галкой}
procedure TForm1.ListBox1ChangeCheck(Sender: TObject);
var
  i: integer;
begin
  SetLength(ItemsCheck, 0);
  for i := 0 to ListBox1.Count-1 do
  begin
    if ListBox1.ListItems[i].IsChecked then
    begin
      SetLength(ItemsCheck, i + 1);
      ItemsCheck[i] := ListBox1.ListItems[i].Text;
    end;
  end;
end;

{Клик по Item'у, вперёд}
procedure TForm1.ListBox1ItemClick(const Sender: TCustomListBox;
  const Item: TListBoxItem);
var
  ExtFile: string;
  mime: JMimeTypeMap;
  ExtToMime: JString;
  Intent: JIntent;
begin
  // Сохраняем выбранный путь
  path := Item.ItemData.Detail;

  if Item.TagString = 'folder' then
  begin
    if TDirectory.Exists(path) then
    begin
      TotalWork(path, True);
    end
    else
    begin
      ListBox1.Items.Delete(Item.Index);
      ShowMessage('Папка не найдена');
    end;
  end
  else if Item.TagString = 'file' then
  begin
    try
      //Определяем расширение файла и его mime тип
      ExtFile := AnsiLowerCase(StringReplace(TPath.GetExtension(path), '.', '',[]));
      mime := TJMimeTypeMap.JavaClass.getSingleton();
      ExtToMime := mime.getMimeTypeFromExtension(StringToJString(ExtFile));

      //Запрашиваем открытие файла
      Intent := TJIntent.Create;
      Intent.setAction(TJIntent.JavaClass.ACTION_VIEW);
      Intent.setDataAndType(StrToJURI('file:' + path), ExtToMime);
      SharedActivity.startActivity(Intent);
    except
      ShowMessage('Невозможно открыть файл!');
    end;
  end;
end;

{Кнопка назад}
procedure TForm1.SpeedButton1Click(Sender: TObject);
begin
  // Определяем предыдущую папку
  path := ExtractFileDir(path);

  if path = '/' then path := '/'
  else path := path;

  TotalWork(path, True);
end;

{Кнопка удалить}
procedure TForm1.SpeedButton2Click(Sender: TObject);
var
  i: integer; // Устанавливаем счётчик
  LItem: TListBoxItem; // Выбранная папка или файл для удаления
  LItemPath: string;
begin

  i := 0; // Обнуляем счётчик

  while i < Length(ItemsCheck) do
  begin

    if ItemsCheck[i]<>'' then
    begin
      // Получаем выбранный пункт
      LItem := ListBox1.ListItems[i];
      LItemPath := LItem.ItemData.Detail;

      if LItem.TagString = 'folder' then
      begin

        if TDirectory.Exists(LItemPath) then
        begin
          TDirectory.Delete(LItemPath, True); // Удаляем папку и подпапки
        end;

        ListBox1.Items.Delete(i); // удаляяем пункт из ListBox'a
        ListBox1ChangeCheck(Self); // обновляем массив выбранных пунктов
        i := 0; // Обнуляем счётчик
      end
      else if LItem.TagString = 'file' then
      begin

        if TFile.Exists(LItemPath) then
        begin
          TFile.Delete(LItemPath); // Удаляем файл
        end;

        ListBox1.Items.Delete(i); // удаляяем пункт из ListBox'a
        ListBox1ChangeCheck(Self); // обновляем массив выбранных пунктов
        i := 0; // Обнуляем счётчик
      end;

    end;
    i := i + 1;
  end;
end;

{Основа для обработки перемещений по файлам}
procedure TForm1.TotalWork(path_tr: string; clear: boolean);
var
  folders, files: TStringDynArray;
begin

  // Выводим текущий путь
  Label1.Text := path_tr;

//*****Папки*****
  // Ищем папки
  folders := TDirectory.GetDirectories(path_tr);

  // Сортируем папки
  TArray.Sort(folders, TComparer.Construct(CompareLowerStr));

  if clear then
  begin
    // Очищаем Листбокс
    ListBox1.Clear;
  end;

  // Заполняем Листбокс списком отсортированных папок
  AddListItem(folders, 'folder');
//***************

//*****Файлы*****
  // Ищем файлы
  files := TDirectory.GetFiles(path_tr);

  // Сортируем файлы
  TArray.Sort(files, TComparer.Construct(CompareLowerStr));

  // Дополняем Листбокс списком отсортированных файлов
  AddListItem(files, 'file');
//***************

end;

end.

Скриншоты:


Видео:

Итог: Приложение отлично работает. Провёл тест, на папке содержащей 400 папок и 570 файлов, общим весом 4,5 Гб. Приложение справилось на отлично.
На скорость чтения папки: По сравнению со стандартным файловым менеджером в самсунге – работают почти одинаково. По сравнению с ES проводником, моё приложение работает заметно медленнее…
В следующей статье добавлю функции создания/переименования файлов и папок, сделаю более дружелюбный вид приложения,  реализую копирование/перемещение файлов и папок. 

Исходный код (Delphi XE5 Update 2): Скачать с Google Drive
Исходный код (Delphi XE6): Скачать с Google Drive

APK файл для установки на ваш девайс: Скачать с Google Drive

На этом всё. Спасибо за внимание. 
Надеюсь, вам понравилась/пригодится данная статья и приложение.

16 комментариев:

  1. Не могли бы вы написать урок по работе с изображениями? Чтобы допустим картинки лежали в папке с кэшем, ну или внутри приложения, и чтобы можно их было переключать/листать кнопкой?

    ОтветитьУдалить
    Ответы
    1. Можно сделать примерно так: Получаем массив картинок в нужной папке с помощью TDirectory.GetFiles. Далее при нажатии на кнопку получаем путь из массива и подгружаем картинку в TImage, сохраняя при этом номер позиции в TImage.Tag. При каждом последующем нажатии извлекаем текущую позицию из TImage.Tag и прибавляем единицу (пока не упрёмся в конец массива), тем самым получаем новый номер позиции в массиве, опять подгружаем новую картинку в TImage и т.д. Попробуйте.

      Удалить
    2. Вот, написал вам маленький примерчик: https://drive.google.com/file/d/0BwEZB8sRo0DSbWlTbUpsaHpPcHM/edit?usp=sharing

      Удалить
  2. Не могли бы Вы написать пример создания собственно окна сообщения используя Android Api - JAlertDialog_Builderб JAlertDialog

    ОтветитьУдалить
  3. Если вопрос про отличие папки от файла ещё актуален, то его можно решить, например так:
    TDirectory::Exists() возвращает true для папки и false для файла, а TFile::Exist() - наоборот. И оба метода одновременно возвращают false для несуществующих объектов.

    ОтветитьУдалить
    Ответы
    1. Вы правы, такой способ имеет место быть. Не помню почему я его отбросил, сейчас кажется отличным способ.

      Удалить
  4. почему при удалении любого файла пишет permission denied?

    ОтветитьУдалить
  5. Доброго дня!!! Прошу помочь. В точности повторяю ваш код (единственное отличие в USES я совместил Uses до имплементейшин и после), с формой те же компоненты, но программа в режиме отладки, да и в последующем запуске без USB шнура не работает, ListBox пустой, может нужно в деплойд что добавить?

    ОтветитьУдалить
  6. Добрый день, подскажите пожалуйста где можно прочитать про файловый менеджер для сохранения или открытия файла определенного формата .csv, нужно для программы сделать, чтобы можно было открывать или сохранять таблицу

    ОтветитьУдалить
    Ответы
    1. Дублирую ответ: "Добрый.
      Готового компонента нет, всё придётся писать самостоятельно." Если задаёте вопрос, то задавайте его только в одном месте.

      Удалить
  7. Помогите пожалуйста!!!! компилятор ругается на StringToJString, на StrToJURI и на SharedActivity.startActivity.... В uses прописано все то же самое что и в этом примере

    ОтветитьУдалить
    Ответы
    1. Вы уверены? У меня всё отлично открывается и работает на XE8. Я специально везде указывал версии... Судя по всему вы используете среду выше XE5, а значит надо было скачать архив для XE6. Методы о которых вы пишите, с версии XE6 объявлены в файле Androidapi.Helpers.pas, соответственно нужно добавить его в Uses.

      Удалить
    2. если быть точнее я использую appmethod, который ничем не отличается от embrcadero, хотя пример скачанный с файлового менеджера работает и компилирует

      Удалить
  8. Добрый вечер, подскажите пожалуйста как прописать код так, чтобы в файловом менеджере отображались папки и расширения формата '*.csv' исключив другие расширения

    ОтветитьУдалить