четверг, 6 февраля 2014 г.

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


В предыдущей статье (#1) я обещал, что продолжу разработку файлового менеджера и добавлю новый функционал:
1) Создание файлов и папок
2) Копирование/перемещение файлов и папок
3) Более дружелюбный интерфейс приложения


Видео в конце поста.
Upd (20.04.14). Проверил код на Delphi XE6 и добавил исходники для новой версии IDE.







Начал я с последнего пункта.
Решил сделать пару иконок, добавить их к пунктам в листбоксе, в зависимости от типа (папка или файл). Так визуально становилось легче определять папки, для дальнейшего перемещения.
Сразу надо оговориться, что я никогда не рисовал иконок и совсем не разбирался с иконками в Android’е. Время пришло! Начал читать в интернете статьи и рекомендации от гугла (раз Metrics and Grids, два: Iconography, три: Devices and Displays), в итоге запутался…
Суть вопроса в том, что для разных экранов (с разным dpi) нужно несколько вариантов одной картинки. Но мне нужно было загружать подходящую картинку в «ListBoxItem», а загрузить туда сразу все варианты нельзя, только одну картинку. Поэтому я не мог решить какой вариант картинки использовать.
Пришлось обратиться за консультацией к Профессионалу (Ярославу Бровину). Ярослав всё подробно объяснил и привёл оригинальное решение для данного вопроса. Для всех кому интересно описание и решение, посетите форум: Как использовать иконки разного качества для экранов с разным DPI?
Ярослав, если вы читаете это сообщение, ещё раз, Спасибо вам! 

В проекте я использовал иконки, базовый размер 48x48:
Для кнопок:
  • MDPI - 1X - 32x32
  • HDPI - 1,5X - 48x48
  • XHDPI - 2X - 64x64
  • XXHDPI – 3X - 96x96
Для листбокса:
  • MDPI - 1X - 48x48
  • HDPI - 1,5X - 64x64
  • XHDPI - 2X - 96x96
Все используемые иконки, в том числе сделанные мной (строго не судите, я вообще рисовать не умею :) залил на гугл драйв (в конце статьи), вдруг кому-то пригодится…

Также теперь подключается стиль, специально сделанный только для «ListBox»(listboxitemnodetail), уменьшающий расстояние между картинкой и текстом в «ListBoxItem».
Вообще для удобства пользователя сделал много чего, теперь вроде стало удобнее пользоваться приложением.

Создание файлов и папок
Принцип: жмём кнопку «Создать файл» или «Создать папку», появляется окно для ввода имени, вводим имя, жмём кнопку «Создать» и файл/папка появляются в списке.

Проблема возникла с созданием окна для ввода имени. Я хотел сделать отдельную форму и вызывать её как модальное окно, но когда всё сделал, обнаружил, что на Android’е вместо прозрачной формы появляется чёрная. Начал экспериментировать, если выставить «FormStyle := fsPopup», то форма появляется прозрачная, но неправильно позиционируется (эта задача решаема) и если на такой форме есть «TEdit», то вы не сможете ни чего ввести, т.к. клавиатура просто не появится. Ярослав был оповещён о подобных проблемах и сказал, что баг уже известен и будет исправлен, также он посоветовал использовать «TCustomPopupForm». В то время я уже делал другой вариант (как временный), который описал в предыдущей статье. Внимание! В данной версии файлового менеджера не используется «TCustomPopupForm» (Почитать можно тут Всплывающие формы в XE5).

Далее техническая часть вопроса. Введённое имя нужно проверять на запрещённые символы. Написал функцию проверки, в коде это «function CheckName». Описывать проверки на существование файла/папки, обработку нажатия кнопок «Enter» и «HardwareBack» я не буду.
Для создания файлов и папок используем методы «TFile.Create» и «TDirectory.CreateDirectory»

Копирование/перемещение файлов и папок
Нам понадобится 3 кнопки «Копировать», «Вставить», «Вырезать». В коде главной кнопкой будет, кнопка «Вставить». 

Принцип: Выбираем пункт (отмечаем галочкой) в «ListBox», жмём кнопку «Копировать/Вырезать», перемещаемся в другую папку, жмём кнопку «Вставить», готово.
Для копирования используем методы «TDirectory.Copy» и «TFile.Copy». Для перемещения «TDirectory.Move» и «TFile.Move». Ловим исключения – выдаём сообщения :).

Переписал процедуру удаления файлов и папок, теперь работает быстрее.


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


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

{
  ******************************************************************************
  FileManager2 for Android
  Autor: Andrey Yefimov (Contact: http://delphifmandroid.blogspot.ru/)
  Use only non-commercial purposes. Necessarily indicating the authorship.
  Используйте только в некоммерческих целях. Обязательно с указанием авторства.
  ******************************************************************************
}

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, FMX.Objects, FMX.Edit, FMX.Effects;

type
  TForm1 = class(TForm)
    ListBox1: TListBox;
    Label1: TLabel;
    ToolBar1: TToolBar;
    SpeedButton1: TSpeedButton;
    SpeedButton2: TSpeedButton;
    SpeedButton3: TSpeedButton;
    ImageBook: TStyleBook;
    SpeedButton4: TSpeedButton;
    Image1: TImage;
    StyleBook1: TStyleBook;
    Image2: TImage;
    Dialog: TRectangle;
    ToolBar2: TToolBar;
    Layout1: TLayout;
    DialogEdit: TEdit;
    DialogTitle: TLabel;
    SpeedButton5: TSpeedButton;
    SpeedButton6: TSpeedButton;
    ShadowEffect1: TShadowEffect;
    DialogFon: TRectangle;
    DialogError: TLabel;
    SpeedButton7: TSpeedButton;
    Image3: TImage;
    SpeedButton8: TSpeedButton;
    ToolBar3: TToolBar;
    Image4: TImage;
    SpeedButton9: TSpeedButton;
    Image5: TImage;
    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);
    procedure SpeedButton3Click(Sender: TObject);
    procedure SpeedButton5Click(Sender: TObject);
    procedure SpeedButton6Click(Sender: TObject);
    procedure SpeedButton4Click(Sender: TObject);
    procedure DialogEditChangeTracking(Sender: TObject);
    procedure SpeedButton8Click(Sender: TObject);
    procedure SpeedButton7Click(Sender: TObject);
    procedure SpeedButton9Click(Sender: TObject);
  private
    procedure AddListItem(list: array of string; itype: string);
    procedure TotalWork(path_tr: string; clear: boolean);
    procedure OnOffButton(del, add, copy, cut: boolean);
    { Private declarations }
  public
    function GetImage(const AImageName: string): TBitmap;
    { 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 array of string; // Массив с помеченными пунктами

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

{Функция получения картинки из ImageBook'а(Автор: Ярослав Бровин)}
function TForm1.GetImage(const AImageName: string): TBitmap;
var
  StyleObject: TFmxObject;
  Image: TImage;
begin
  StyleObject := ImageBook.Style.FindStyleResource(AImageName);
  if (StyleObject <> nil) and (StyleObject is TImage) then
  begin
    Image := StyleObject as TImage;
    // Здесь мы получим картинку нужного dpi (Scale)
    Result := Image.Bitmap;
  end
  else
    Result := nil;
end;

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

  BitmapFolder := GetImage('folder');
  BitmapFile := GetImage('file');

  ListBox1.BeginUpdate;

  for c := 0 to Length(list) - 1 do
  begin

    LItem := TListBoxItem.Create(ListBox1);

    if itype = 'folder' then
    begin
      if BitmapFolder <> nil then
      begin
        LItem.ItemData.Bitmap.Assign(BitmapFolder);
      end;
    end
    else
    begin
      if BitmapFile <> nil then begin
        LItem.ItemData.Bitmap.Assign(BitmapFile);
      end;
    end;

    LItem.ItemData.Text := ExtractFileName(list[c]);
    LItem.ItemData.Detail := list[c]; // Помещаем полный путь в Detail
    LItem.TagString := itype;
    ListBox1.AddObject(LItem);

  end;

  ListBox1.EndUpdate;

end;

{Функция для проверки введённого имени}
function CheckName(NewName: string): Boolean;
const
  InvalidChars: Array[0..9] of Char = ('\', '/', ':', '*', '?', '"', '<', '>', '|', '~');
var
  LengthName, i, j: integer;
begin

  Result := False;

  LengthName := Length(NewName);

  if LengthName <> 0 then
  begin
    for i := 0 to LengthName - 1 do
    begin
      for j := 0 to 9 do
      begin
        if NewName[i] = InvalidChars[j] then
        begin
          Result := True;
        end;
      end;
    end;
  end;

end;

{Отслеживаем ввод символов и проверяем их}
procedure TForm1.DialogEditChangeTracking(Sender: TObject);
begin
  if CheckName(DialogEdit.Text) then
    DialogError.Text := 'Недопускаются \ / : * ? " < > | ~'
  else
    DialogError.Text := '';
end;

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

  TotalWork(path, False);
end;

{Обрабатываем кнопки Hardware Back, Enter}
procedure TForm1.FormKeyUp(Sender: TObject; var Key: Word; var KeyChar: Char;
  Shift: TShiftState);
begin
  if Dialog.Visible = False then
  begin

    if (Key = vkHardwareBack) AND (path <> '/') then
    begin
      SpeedButton1Click(Self); // Вызываем кнопку Назад
      Key := 0;
    end;

  end
  else
  begin
    if Key = vkHardwareBack then
    begin
      SpeedButton5Click(Self); // Вызываем кнопку Отмена
      Key := 0;
    end;

    if Key = 13 then
    begin
      SpeedButton6Click(Self); // Вызываем кнопку Создать
    end;
  end;
end;

{Пункт пометили галкой}
procedure TForm1.ListBox1ChangeCheck(Sender: TObject);
var
  i: integer;
begin
  SetLength(ItemsCheck, 0, 0);

  for i := 0 to ListBox1.Count-1 do
  begin

    if ListBox1.ListItems[i].IsChecked then
    begin
      SetLength(ItemsCheck, i + 1, 3);
      ItemsCheck[i][0] := ListBox1.ListItems[i].Text; // Имя
      ItemsCheck[i][1] := ListBox1.ListItems[i].TagString; // Тип
      ItemsCheck[i][2] := ListBox1.ListItems[i].ItemData.Detail; // Путь
    end;

  end;

  if Length(ItemsCheck) <> 0 then begin
    OnOffButton(True, False, True, True);
  end
  else begin
    OnOffButton(False, False, False, False);
  end;

end;

{Клик по Item'у, вперёд}
procedure TForm1.ListBox1ItemClick(const Sender: TCustomListBox;
  const Item: TListBoxItem);
var
  FileName, ExtFile: string;
  mime: JMimeTypeMap;
  ExtToMime: JString;
  Intent: JIntent;
begin

  if Length(ItemsCheck) <> 0 then begin
    OnOffButton(False, True, False, False);
  end
  else begin
    OnOffButton(False, False, False, False);
  end;

  if Item.TagString = 'folder' then
  begin

    // Сохраняем выбранный путь
    path := Item.ItemData.Detail;

    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

    // Получаем путь до файла
    FileName := Item.ItemData.Detail;

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

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

{Управляем кнопками}
procedure TForm1.OnOffButton(del, add, copy, cut: boolean);
begin
    SpeedButton2.Enabled := del; // кнопка Удаления
    SpeedButton7.Enabled := add; // кнопка Вставить
    SpeedButton8.Enabled := copy; // кнопка Копировать
    SpeedButton9.Enabled := cut; // кнопка Вырезать
end;

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

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

  TotalWork(path, True);

  if Length(ItemsCheck) <> 0 then begin
    OnOffButton(False, True, False, False);
  end
  else begin
    OnOffButton(False, False, False, False);
  end;
end;

{Кнопка удалить}
procedure TForm1.SpeedButton2Click(Sender: TObject);
var
  i: integer; // Устанавливаем счётчик
  LItemPath: string;
  msg: integer;
begin
  msg := MessageDlg('Удалить выбранные файлы?', System.UITypes.TMsgDlgType.mtConfirmation,
  [
    System.UITypes.TMsgDlgBtn.mbYes,
    System.UITypes.TMsgDlgBtn.mbNo
  ], 0);

  if msg = mrYes then
  begin

    for i := 0 to Length(ItemsCheck) - 1 do
    begin

      if ItemsCheck[i][0] <> '' then
      begin
        // Получаем путь из массива
        LItemPath := ItemsCheck[i][2];

        if ItemsCheck[i][1] = 'folder' then begin

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

        end
        else if ItemsCheck[i][1] = 'file' then begin

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

        end;
      end;
    end;
    TotalWork(path, True); // Обновляем список
    ListBox1ChangeCheck(Self); // Чистим массив и т.д.
  end;
end;

{Кнопка для создания файла - выводим окно для ввода имени файла}
procedure TForm1.SpeedButton3Click(Sender: TObject);
begin
  DialogTitle.Text := 'Введите имя файла';
  DialogEdit.TagString := 'CreateFile';
  DialogFon.Visible := True;
  Dialog.Visible := True;
end;

{Кнопка для создания папки - выводим окно для ввода имени папки}
procedure TForm1.SpeedButton4Click(Sender: TObject);
begin
  DialogTitle.Text := 'Введите имя папки';
  DialogEdit.TagString := 'CreateFolder';
  DialogFon.Visible := True;
  Dialog.Visible := True;
end;

{Кастомное окно - кнопка Отмена - "закрываем" окно}
procedure TForm1.SpeedButton5Click(Sender: TObject);
begin
  DialogEdit.Text := '';
  DialogError.Text := '';
  Dialog.Visible := False;
  DialogFon.Visible := False;
end;

{Кастомное окно - кнопка Создать - создаём папку или файл}
procedure TForm1.SpeedButton6Click(Sender: TObject);
var
  newpath: string;
  newfile: TFileStream;
begin

  if (Length(DialogEdit.Text) = 0) OR (DialogEdit.Text = ' ') then begin
    DialogError.Text := 'Введите имя!';
    Exit;
  end;

  newpath := path + PathDelim + DialogEdit.Text;

  if DialogEdit.TagString = 'CreateFile' then
  begin
    if TFile.Exists(newpath) then begin
      ShowMessage('Файл существует!');
    end
    else begin
      newfile := TFile.Create(newpath); // Создаём файл(поток)
      newfile.Free; // Очищаем поток
      SpeedButton5Click(Self); // Закрываем окно
      TotalWork(path, True); // Обновляем список
    end;
  end
  else if DialogEdit.TagString = 'CreateFolder' then
  begin
    if TDirectory.Exists(newpath) then begin
      ShowMessage('Папка существует!');
    end
    else begin
      TDirectory.CreateDirectory(newpath); // Создаём папку
      SpeedButton5Click(Self); // Закрываем окно
      TotalWork(path, True); // Обновляем список
    end;
  end;
end;

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

  for i := 0 to Length(ItemsCheck) - 1 do
  begin

    if ItemsCheck[i][0]<>'' then
    begin
      // Получаем путь из массива
      LItemPath := ItemsCheck[i][2];

      if ItemsCheck[i][1] = 'folder' then begin
        try
          if SpeedButton9.Tag <> 1 then begin
            TDirectory.Copy(LItemPath, path + PathDelim + ItemsCheck[i][0]); // Копируем папку
          end
          else begin
            TDirectory.Move(LItemPath, path + PathDelim + ItemsCheck[i][0]); // Перемещаем папку
          end;
        except
          ShowMessage('Копирование папки ' + ItemsCheck[i][0] + ' не возможно!');
        end;
      end
      else if ItemsCheck[i][1] = 'file' then begin
        try
          if SpeedButton9.Tag <> 1 then begin
            TFile.Copy(LItemPath, path + PathDelim + ItemsCheck[i][0], True); // Копируем файл
          end
          else begin
            TFile.Move(LItemPath, path + PathDelim + ItemsCheck[i][0]); // Перемещаем файл
          end;
        except
          ShowMessage('Копирование файла ' + ItemsCheck[i][0] + ' не возможно!');
        end;
      end;
    end;
  end;
  SpeedButton9.Tag := 0;
  TotalWork(path, True); // Обновляем список
  ListBox1ChangeCheck(Self); // Чистим массив и т.д.
end;

{кнопка Копировать}
procedure TForm1.SpeedButton8Click(Sender: TObject);
begin
  if Length(ItemsCheck) <> 0 then
  begin
    ShowMessage('Скопировали в буфер, перейдите в другую папку для вставки объектов');
  end;
end;

{кнопка Вырезать/перместить}
procedure TForm1.SpeedButton9Click(Sender: TObject);
begin
  if Length(ItemsCheck) <> 0 then
  begin
    SpeedButton9.Tag := 1;
    ShowMessage('Вырезали в буфер, перейдите в другую папку для вставки объектов');
  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.

Скриншоты:



Видео:
Новая версия приложения

Старая версия приложения


Когда посвящаешь себя полностью делу, которое нравится, начинаешь параллельно думать, как и что можно улучшить/сделать лучше.
Вот у меня так и произошло, пока дописывал основной функционал (Копирование/перемещение) приложения, в голове параллельно появлялись новые мысли по улучшению приложения, поэтому пришлось создать текстовый документ, в который записывалась каждая пришедшая мысль. В итоге, когда дописал основной функционал, в текстовом документе было уже 12 строчек, в этой версии я реализовал 4 мысли, некоторые идеи пока даже не знаю как реализовать, но с ними приложение стало бы ещё немного дружелюбнее/удобнее. Возможно, будет ещё одна часть, зависит от вашего мнения/лайков.



Исходный код (Delphi XE5 Update 2): Скачать с Google Drive
Исходный код (Delphi XE6): Скачать с Google Drive
APK файл для установки на ваш девайс: Скачать с Google Drive
Иконки: Скачать с Google Drive

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

p.s.1 Пока добавлял пост заметил, что количество строк выросло с 258 до 551,  можно уменьшить и сохранить при этом читабельность кода, но в моих целях этого пока не было.