content: /*
   This file is part of Subsonic.
  
   Subsonic 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.
  
   Subsonic 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 Subsonic.  If not, see <http://www.gnu.org/licenses/>.
  
   Copyright 2009 (C) Sindre Mehus
   */
  package net.nullsum.audinaut.util;
  
  import android.content.Context;
  import android.graphics.Bitmap;
  import android.graphics.BitmapFactory;
  import android.os.Build;
  import android.os.Environment;
  import android.util.Log;
  
  import androidx.core.content.ContextCompat;
  
  import com.esotericsoftware.kryo.Kryo;
  import com.esotericsoftware.kryo.io.Input;
  import com.esotericsoftware.kryo.io.Output;
  
  import net.nullsum.audinaut.domain.Artist;
  import net.nullsum.audinaut.domain.Genre;
  import net.nullsum.audinaut.domain.Indexes;
  import net.nullsum.audinaut.domain.MusicDirectory;
  import net.nullsum.audinaut.domain.MusicFolder;
  import net.nullsum.audinaut.domain.Playlist;
  import net.nullsum.audinaut.service.MediaStoreService;
  
  import java.io.BufferedWriter;
  import java.io.File;
  import java.io.FileInputStream;
  import java.io.FileNotFoundException;
  import java.io.FileOutputStream;
  import java.io.FileWriter;
  import java.io.IOException;
  import java.io.RandomAccessFile;
  import java.io.Serializable;
  import java.util.Arrays;
  import java.util.Collections;
  import java.util.HashMap;
  import java.util.Iterator;
  import java.util.List;
  import java.util.SortedSet;
  import java.util.TreeSet;
  import java.util.zip.DeflaterOutputStream;
  import java.util.zip.InflaterInputStream;
  
  /**
   * @author Sindre Mehus
   */
  public class FileUtil {
  
      private static final String TAG = FileUtil.class.getSimpleName();
      private static final String[] FILE_SYSTEM_UNSAFE = {"/", "\\", "..", ":", "\"", "?", "*", "<", ">", "|"};
      private static final String[] FILE_SYSTEM_UNSAFE_DIR = {"\\", "..", ":", "\"", "?", "*", "<", ">", "|"};
      private static final List<String> MUSIC_FILE_EXTENSIONS = Arrays.asList("mp3", "ogg", "opus", "aac", "flac", "m4a", "wav", "wma");
      private static final List<String> PLAYLIST_FILE_EXTENSIONS = Collections.singletonList("m3u");
      private static final int MAX_FILENAME_LENGTH = 254 - ".complete.mp3".length();
      private static final Kryo kryo = new Kryo();
      private static File DEFAULT_MUSIC_DIR;
      private static HashMap<String, MusicDirectory.Entry> entryLookup;
  
      static {
          kryo.register(MusicDirectory.Entry.class);
          kryo.register(Indexes.class);
          kryo.register(Artist.class);
          kryo.register(MusicFolder.class);
          kryo.register(Playlist.class);
          kryo.register(Genre.class);
      }
  
      public static File getEntryFile(Context context, MusicDirectory.Entry entry) {
          if (entry.isDirectory()) {
              return getAlbumDirectory(context, entry);
          } else {
              return getSongFile(context, entry);
          }
      }
  
      public static File getSongFile(Context context, MusicDirectory.Entry song) {
          File dir = getAlbumDirectory(context, song);
  
          StringBuilder fileName = new StringBuilder();
          Integer track = song.getTrack();
          if (track != null) {
              if (track < 10) {
                  fileName.append("0");
              }
              fileName.append(track).append("-");
          }
  
          fileName.append(fileSystemSafe(song.getTitle()));
          if (fileName.length() >= MAX_FILENAME_LENGTH) {
              fileName.setLength(MAX_FILENAME_LENGTH);
          }
  
          fileName.append(".");
          if (song.getTranscodedSuffix() != null) {
              fileName.append(song.getTranscodedSuffix());
          } else {
              fileName.append(song.getSuffix());
          }
  
          return new File(dir, fileName.toString());
      }
  
      public static File getPlaylistFile(Context context, String server, String name) {
          File playlistDir = getPlaylistDirectory(context, server);
          return new File(playlistDir, fileSystemSafe(name) + ".m3u");
      }
  
      public static void writePlaylistFile(Context context, File file, MusicDirectory playlist) throws IOException {
          FileWriter fw = new FileWriter(file);
          BufferedWriter bw = new BufferedWriter(fw);
          try {
              fw.write("#EXTM3U\n");
              for (MusicDirectory.Entry e : playlist.getChildren()) {
                  String filePath = FileUtil.getSongFile(context, e).getAbsolutePath();
                  if (!new File(filePath).exists()) {
                      String ext = FileUtil.getExtension(filePath);
                      String base = FileUtil.getBaseName(filePath);
                      filePath = base + ".complete." + ext;
                  }
                  fw.write(filePath + "\n");
              }
          } catch (Exception e) {
              Log.w(TAG, "Failed to save playlist: " + playlist.getName());
          } finally {
              bw.close();
              fw.close();
          }
      }
  
      public static File getPlaylistDirectory(Context context) {
          File playlistDir = new File(getSubsonicDirectory(context), "playlists");
          ensureDirectoryExistsAndIsReadWritable(playlistDir);
          return playlistDir;
      }
  
      public static File getPlaylistDirectory(Context context, String server) {
          File playlistDir = new File(getPlaylistDirectory(context), server);
          ensureDirectoryExistsAndIsReadWritable(playlistDir);
          return playlistDir;
      }
  
      public static File getAlbumArtFile(Context context, MusicDirectory.Entry entry) {
          if (entry.getId().contains(ImageLoader.PLAYLIST_PREFIX)) {
              File dir = getAlbumArtDirectory(context);
              return new File(dir, Util.md5Hex(ImageLoader.PLAYLIST_PREFIX + entry.getTitle()) + ".jpeg");
          } else {
              File albumDir = getAlbumDirectory(context, entry);
              File artFile;
              File albumFile = getAlbumArtFile(albumDir);
              File hexFile = getHexAlbumArtFile(context, albumDir);
              if (albumDir.exists()) {
                  if (hexFile.exists()) {
                      hexFile.renameTo(albumFile);
                  }
                  artFile = albumFile;
              } else {
                  artFile = hexFile;
              }
              return artFile;
          }
      }
  
      private static File getAlbumArtFile(File albumDir) {
          return new File(albumDir, Constants.ALBUM_ART_FILE);
      }
  
      private static File getHexAlbumArtFile(Context context, File albumDir) {
          return new File(getAlbumArtDirectory(context), Util.md5Hex(albumDir.getPath()) + ".jpeg");
      }
  
      public static Bitmap getAlbumArtBitmap(Context context, MusicDirectory.Entry entry, int size) {
          File albumArtFile = getAlbumArtFile(context, entry);
          if (albumArtFile.exists()) {
              final BitmapFactory.Options opt = new BitmapFactory.Options();
              opt.inJustDecodeBounds = true;
              BitmapFactory.decodeFile(albumArtFile.getPath(), opt);
              opt.inPurgeable = true;
              opt.inSampleSize = Util.calculateInSampleSize(opt, size, Util.getScaledHeight(opt.outHeight, opt.outWidth, size));
              opt.inJustDecodeBounds = false;
  
              Bitmap bitmap = BitmapFactory.decodeFile(albumArtFile.getPath(), opt);
              return bitmap == null ? null : getScaledBitmap(bitmap, size);
          }
          return null;
      }
  
      public static Bitmap getSampledBitmap(byte[] bytes, int size) {
          final BitmapFactory.Options opt = new BitmapFactory.Options();
          opt.inJustDecodeBounds = true;
          BitmapFactory.decodeByteArray(bytes, 0, bytes.length, opt);
          opt.inPurgeable = true;
          opt.inSampleSize = Util.calculateInSampleSize(opt, size, Util.getScaledHeight(opt.outHeight, opt.outWidth, size));
          opt.inJustDecodeBounds = false;
          Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, opt);
          if (bitmap == null) {
              return null;
          } else {
              return getScaledBitmap(bitmap, size);
          }
      }
  
      private static Bitmap getScaledBitmap(Bitmap bitmap, int size) {
          // Don't waste time scaling if the difference is minor
          // Large album arts still need to be scaled since displayed as is on now playing!
          if (size < 400 && bitmap.getWidth() < (size * 1.1)) {
              return bitmap;
          } else {
              return Bitmap.createScaledBitmap(bitmap, size, Util.getScaledHeight(bitmap, size), true);
          }
      }
  
      public static File getAlbumArtDirectory(Context context) {
          File albumArtDir = new File(getSubsonicDirectory(context), "artwork");
          ensureDirectoryExistsAndIsReadWritable(albumArtDir);
          ensureDirectoryExistsAndIsReadWritable(new File(albumArtDir, ".nomedia"));
          return albumArtDir;
      }
  
      public static File getArtistDirectory(Context context, Artist artist) {
          return new File(getMusicDirectory(context).getPath() + "/" + fileSystemSafe(artist.getName()));
      }
  
      public static File getArtistDirectory(Context context, MusicDirectory.Entry artist) {
          return new File(getMusicDirectory(context).getPath() + "/" + fileSystemSafe(artist.getTitle()));
      }
  
      public static File getAlbumDirectory(Context context, MusicDirectory.Entry entry) {
          File dir = null;
          if (entry.getPath() != null) {
              File f = new File(fileSystemSafeDir(entry.getPath()));
              String folder = getMusicDirectory(context).getPath();
              if (entry.isDirectory()) {
                  folder += "/" + f.getPath();
              } else if (f.getParent() != null) {
                  folder += "/" + f.getParent();
              }
              dir = new File(folder);
          } else {
              MusicDirectory.Entry firstSong;
              if (!Util.isOffline(context)) {
                  firstSong = lookupChild(context, entry, false);
                  if (firstSong != null) {
                      File songFile = FileUtil.getSongFile(context, firstSong);
                      dir = songFile.getParentFile();
                  }
              }
  
              if (dir == null) {
                  String artist = fileSystemSafe(entry.getArtist());
                  String album = fileSystemSafe(entry.getAlbum());
                  if ("unnamed".equals(album)) {
                      album = fileSystemSafe(entry.getTitle());
                  }
                  dir = new File(getMusicDirectory(context).getPath() + "/" + artist + "/" + album);
              }
          }
          return dir;
      }
  
      public static MusicDirectory.Entry lookupChild(Context context, MusicDirectory.Entry entry, boolean allowDir) {
          // Initialize lookupMap if first time called
          String lookupName = Util.getCacheName(context);
          if (entryLookup == null) {
              entryLookup = deserialize(context, lookupName, HashMap.class);
  
              // Create it if
              if (entryLookup == null) {
                  entryLookup = new HashMap<>();
              }
          }
  
          // Check if this lookup has already been done before
          MusicDirectory.Entry child = entryLookup.get(entry.getId());
          if (child != null) {
              return child;
          }
  
          // Do a special lookup since 4.7+ doesn't match artist/album to entry.getPath
          String s = Util.getRestUrl(context, null, false, null) + entry.getId();
          String cacheName = "album-" + s.hashCode() + ".ser";
          MusicDirectory entryDir = FileUtil.deserialize(context, cacheName, MusicDirectory.class);
  
          if (entryDir != null) {
              List<MusicDirectory.Entry> songs = entryDir.getChildren(allowDir, true);
              if (songs.size() > 0) {
                  child = songs.get(0);
                  entryLookup.put(entry.getId(), child);
                  serialize(context, entryLookup, lookupName);
                  return child;
              }
          }
  
          return null;
      }
  
      public static void createDirectoryForParent(File file) {
          File dir = file.getParentFile();
          if (!dir.exists()) {
              if (!dir.mkdirs()) {
                  Log.e(TAG, "Failed to create directory " + dir);
              }
          }
      }
  
      public static File getSubsonicDirectory(Context context) {
          return context.getExternalFilesDir(null);
      }
  
      public static File getDefaultMusicDirectory(Context context) {
          if (DEFAULT_MUSIC_DIR == null) {
              File[] dirs;
              if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                  dirs = context.getExternalMediaDirs();
              } else {
                  dirs = ContextCompat.getExternalFilesDirs(context, null);
              }
  
              DEFAULT_MUSIC_DIR = new File(getBestDir(dirs), "music");
  
              if (!DEFAULT_MUSIC_DIR.exists() && !DEFAULT_MUSIC_DIR.mkdirs()) {
                  Log.e(TAG, "Failed to create default dir " + DEFAULT_MUSIC_DIR);
  
                  // Some devices seem to have screwed up the new media directory API.  Go figure.  Try again with standard locations
                  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                      dirs = ContextCompat.getExternalFilesDirs(context, null);
  
                      DEFAULT_MUSIC_DIR = new File(getBestDir(dirs), "music");
                      if (!DEFAULT_MUSIC_DIR.exists() && !DEFAULT_MUSIC_DIR.mkdirs()) {
                          Log.e(TAG, "Failed to create default dir " + DEFAULT_MUSIC_DIR);
                      } else {
                          Log.w(TAG, "Stupid OEM's messed up media dir API added in 5.0");
                      }
                  }
              }
          }
  
          return DEFAULT_MUSIC_DIR;
      }
  
      private static File getBestDir(File[] dirs) {
          // Past 5.0 we can query directly for SD Card
          if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
              for (File dir : dirs) {
                  try {
                      if (dir != null && Environment.isExternalStorageRemovable(dir)) {
                          return dir;
                      }
                  } catch (Exception e) {
                      Log.e(TAG, "Failed to check if is external", e);
                  }
              }
          }
  
          // Before 5.0, we have to guess.  Most of the time the SD card is last
          for (int i = dirs.length - 1; i >= 0; i--) {
              if (dirs[i] != null) {
                  return dirs[i];
              }
          }
  
          // Should be impossible to be reached
          return dirs[0];
      }
  
      public static File getMusicDirectory(Context context) {
          String path = Util.getPreferences(context).getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, getDefaultMusicDirectory(context).getPath());
          File dir = new File(path);
          return ensureDirectoryExistsAndIsReadWritable(dir) ? dir : getDefaultMusicDirectory(context);
      }
  
      public static void deleteMusicDirectory(Context context) {
          File musicDirectory = FileUtil.getMusicDirectory(context);
          MediaStoreService mediaStore = new MediaStoreService(context);
          recursiveDelete(musicDirectory, mediaStore);
      }
  
      public static void deleteSerializedCache(Context context) {
          for (File file : context.getCacheDir().listFiles()) {
              if (file.getName().contains(".ser")) {
                  file.delete();
              }
          }
      }
  
      public static void deleteArtworkCache(Context context) {
          File artDirectory = FileUtil.getAlbumArtDirectory(context);
          recursiveDelete(artDirectory);
      }
  
      private static void recursiveDelete(File dir) {
          recursiveDelete(dir, null);
      }
  
      public static boolean recursiveDelete(File dir, MediaStoreService mediaStore) {
          if (dir != null && dir.exists()) {
              File[] list = dir.listFiles();
              if (list != null) {
                  for (File file : list) {
                      if (file.isDirectory()) {
                          if (!recursiveDelete(file, mediaStore)) {
                              return false;
                          }
                      } else if (file.exists()) {
                          if (!file.delete()) {
                              return false;
                          } else if (mediaStore != null) {
                              mediaStore.deleteFromMediaStore(file);
                          }
                      }
                  }
              }
              return dir.delete();
          }
          return false;
      }
  
      public static void deleteEmptyDir(File dir) {
          try {
              File[] children = dir.listFiles();
              if (children == null) {
                  return;
              }
  
              // No songs left in the folder
              if (children.length == 1 && children[0].getPath().equals(FileUtil.getAlbumArtFile(dir).getPath())) {
                  Util.delete(children[0]);
                  children = dir.listFiles();
              }
  
              // Delete empty directory
              if (children.length == 0) {
                  Util.delete(dir);
              }
          } catch (Exception e) {
              Log.w(TAG, "Error while trying to delete empty dir", e);
          }
      }
  
      private static boolean ensureDirectoryExistsAndIsReadWritable(File dir) {
          if (dir == null) {
              return false;
          }
  
          if (dir.exists()) {
              if (!dir.isDirectory()) {
                  Log.w(TAG, dir + " exists but is not a directory.");
                  return false;
              }
          } else {
              if (dir.mkdirs()) {
                  Log.i(TAG, "Created directory " + dir);
              } else {
                  Log.w(TAG, "Failed to create directory " + dir);
                  return false;
              }
          }
  
          if (!dir.canRead()) {
              Log.w(TAG, "No read permission for directory " + dir);
              return false;
          }
  
          if (!dir.canWrite()) {
              Log.w(TAG, "No write permission for directory " + dir);
              return false;
          }
          return true;
      }
  
      public static boolean verifyCanWrite(File dir) {
          if (ensureDirectoryExistsAndIsReadWritable(dir)) {
              try {
                  File tmp = new File(dir, "checkWrite");
                  tmp.createNewFile();
                  if (tmp.exists()) {
                      if (tmp.delete()) {
                          return true;
                      } else {
                          Log.w(TAG, "Failed to delete temp file, retrying");
  
                          // This should never be reached since this is a file Audinaut created!
                          Thread.sleep(100L);
                          tmp = new File(dir, "checkWrite");
                          if (tmp.delete()) {
                              return true;
                          } else {
                              Log.w(TAG, "Failed retry to delete temp file");
                              return false;
                          }
                      }
                  } else {
                      Log.w(TAG, "Temp file does not actually exist");
                      return false;
                  }
              } catch (Exception e) {
                  Log.w(TAG, "Failed to create tmp file", e);
                  return false;
              }
          } else {
              return false;
          }
      }
  
      /**
       * Makes a given filename safe by replacing special characters like slashes ("/" and "\")
       * with dashes ("-").
       *
       * @param filename The filename in question.
       * @return The filename with special characters replaced by hyphens.
       */
      private static String fileSystemSafe(String filename) {
          if (filename == null || filename.trim().length() == 0) {
              return "unnamed";
          }
  
          for (String s : FILE_SYSTEM_UNSAFE) {
              filename = filename.replace(s, "-");
          }
          return filename;
      }
  
      /**
       * Makes a given filename safe by replacing special characters like colons (":")
       * with dashes ("-").
       *
       * @param path The path of the directory in question.
       * @return The the directory name with special characters replaced by hyphens.
       */
      private static String fileSystemSafeDir(String path) {
          if (path == null || path.trim().length() == 0) {
              return "";
          }
  
          for (String s : FILE_SYSTEM_UNSAFE_DIR) {
              path = path.replace(s, "-");
          }
          return path;
      }
  
      /**
       * Similar to {@link File#listFiles()}, but returns a sorted set.
       * Never returns {@code null}, instead a warning is logged, and an empty set is returned.
       */
      public static SortedSet<File> listFiles(File dir) {
          File[] files = dir.listFiles();
          if (files == null) {
              Log.w(TAG, "Failed to list children for " + dir.getPath());
              return new TreeSet<>();
          }
  
          return new TreeSet<>(Arrays.asList(files));
      }
  
      public static SortedSet<File> listMediaFiles(File dir) {
          SortedSet<File> files = listFiles(dir);
          Iterator<File> iterator = files.iterator();
          while (iterator.hasNext()) {
              File file = iterator.next();
              if (!file.isDirectory() && !isMediaFile(file)) {
                  iterator.remove();
              }
          }
          return files;
      }
  
      private static boolean isMediaFile(File file) {
          String extension = getExtension(file.getName());
          return MUSIC_FILE_EXTENSIONS.contains(extension);
      }
  
      public static boolean isPlaylistFile(File file) {
          String extension = getExtension(file.getName());
          return PLAYLIST_FILE_EXTENSIONS.contains(extension);
      }
  
      /**
       * Returns the extension (the substring after the last dot) of the given file. The dot
       * is not included in the returned extension.
       *
       * @param name The filename in question.
       * @return The extension, or an empty string if no extension is found.
       */
      public static String getExtension(String name) {
          int index = name.lastIndexOf('.');
          return index == -1 ? "" : name.substring(index + 1).toLowerCase();
      }
  
      /**
       * Returns the base name (the substring before the last dot) of the given file. The dot
       * is not included in the returned basename.
       *
       * @param name The filename in question.
       * @return The base name, or an empty string if no basename is found.
       */
      public static String getBaseName(String name) {
          int index = name.lastIndexOf('.');
          return index == -1 ? name : name.substring(0, index);
      }
  
      public static <T extends Serializable> void serialize(Context context, T obj, String fileName) {
          Output out = null;
          try {
              RandomAccessFile file = new RandomAccessFile(context.getCacheDir() + "/" + fileName, "rw");
              out = new Output(new FileOutputStream(file.getFD()));
              synchronized (kryo) {
                  kryo.writeObject(out, obj);
              }
          } catch (Throwable x) {
              Log.w(TAG, "Failed to serialize object to " + fileName);
          } finally {
              Util.close(out);
          }
      }
  
      public static <T extends Serializable> T deserialize(Context context, String fileName, Class<T> tClass) {
          Input in = null;
          try {
              File file = new File(context.getCacheDir(), fileName);
              if (!file.exists()) {
                  return null;
              }
  
              RandomAccessFile randomFile = new RandomAccessFile(file, "r");
  
              in = new Input(new FileInputStream(randomFile.getFD()));
              synchronized (kryo) {
                  return kryo.readObject(in, tClass);
              }
          } catch (FileNotFoundException e) {
              // Different error message
              Log.w(TAG, "No serialization for object from " + fileName);
              return null;
          } catch (Throwable x) {
              Log.w(TAG, "Failed to deserialize object from " + fileName);
              return null;
          } finally {
              Util.close(in);
          }
      }
  
      public static <T extends Serializable> void serializeCompressed(Context context, T obj, String fileName) {
          Output out = null;
          try {
              RandomAccessFile file = new RandomAccessFile(context.getCacheDir() + "/" + fileName, "rw");
              out = new Output(new DeflaterOutputStream(new FileOutputStream(file.getFD())));
              synchronized (kryo) {
                  kryo.writeObject(out, obj);
              }
          } catch (Throwable x) {
              Log.w(TAG, "Failed to serialize compressed object to " + fileName);
          } finally {
              Util.close(out);
          }
      }
  
      public static <T extends Serializable> T deserializeCompressed(Context context, String fileName, Class<T> tClass) {
          Input in = null;
          try {
              RandomAccessFile file = new RandomAccessFile(context.getCacheDir() + "/" + fileName, "r");
  
              in = new Input(new InflaterInputStream(new FileInputStream(file.getFD())));
              synchronized (kryo) {
                  return kryo.readObject(in, tClass);
              }
          } catch (FileNotFoundException e) {
              // Different error message
              Log.w(TAG, "No serialization compressed for object from " + fileName);
              return null;
          } catch (Throwable x) {
              Log.w(TAG, "Failed to deserialize compressed object from " + fileName);
              return null;
          } finally {
              Util.close(in);
          }
      }
  }
  