#!/bin/bash
###############################################################################
# adbsync
# Synchronize your Android device with your Linux machine using rsync and adbfs
# © 2022, 2023 by Andreas Itzchak Rehberg
# Licensed using GPLv2 (see the file LICENSE which shipped with this)
###############################################################################

# ######################################[ Configuration ]###
#--[ defaults which can be overridden by CLI parameters ]=--
SPECFILE=
LOGFILE=
SILENT=0
DRYRUN=0
DEBUG=0

#--=[ exit codes ]=--
ERR_SYNTAX=1
ERR_NO_SPECFILE=5
ERR_NO_SPEC_DEVICES=6
ERR_DEP_MISSING=10
ERR_MOUNTBASE_MISSING=11
ERR_MOUNT_BUSY=12
ERR_SIGTERM=98
ERR_USER_ABORT=99

# ######################################[ Code, no Conf ]###
#--=[ handle error output ]=--
stderr() {
  >&2 echo "$(date +"%F %H:%M:%S") $1"
  case "${LOGFILE}" in
    syslog) logger -e -t adbsync -p err "$1" ;;
    none|"") ;;
    *) echo "$(date +"%F %H:%M:%S") $1" >> "${LOGFILE}" ;;
  esac
}
#--=[ and standard output ]=--
stdout() {
  [[ $SILENT -eq 0 ]] && echo "$(date +"%F %H:%M:%S") $1"
  case "${LOGFILE}" in
    syslog) logger -e -t adbsync "$1" ;;
    none|"") ;;
    *) echo "$(date +"%F %H:%M:%S") $1" >> "${LOGFILE}" ;;
  esac
}

#--=[ shorthand for error exit with cleanup ]=--
# $1 - message
# $2 - exit code
errExit() {
  stdout "Exit-on-Error, cleaning up"
  stdout ""
  mount | grep "$MOUNTBASE" >/dev/null && umountdroid "Android device"
  stderr "$1"
  exit $2
}

#--=[ show help screen ]=--
syntax() {
  echo
  echo "Synchronize your Android device with your Linux machine using rsync and adbfs"
  echo
  echo "Syntax:"
  echo "  $0 [options]"
  echo "Options:"
  echo "  -d        dry run (just report what would be done, no syncs performed)"
  echo "  -D        enable debug mode (e.g. also output each command run)"
  echo "  -h        help (show this help)"
  echo "  -l <log>  use <log> to log progress/errors (default: what's specified in the SPECFILE)."
  echo "            Apart from file names, use 'none' for no file logging or 'syslog' to log to system log"
  echo "  -q        quiet (do not report progress, only errors)"
  echo "  -s <JSON> JSON file with settings (default: './devices.json', '~/.config/adbsync/devices.json')"
  echo
}

#--=[ shortcut to jq so we don't have to always specify the $SPECFILE ]=--
function jqs() {
  jq $@ $SPECFILE
}

#--=[ execute a command ]=--
function runcmd() {
  if [[ $DRYRUN -ne 0 ]]; then
    stdout "$1"
  else
    [[ $DEBUG -gt 0 ]] && stdout "$1"
    eval $1
  fi
}

#--=[ check command line parameters ]=--
optstring="dDhl:m:qs:"
while getopts ${optstring} arg; do
  case ${arg} in
    d) DRYRUN=1 ;;
    D) DEBUG=1 ;;
    l) LOGFILE="$OPTARG" ;;
    m) MOUNTBASE="$OPTARG" ;;
    q) SILENT=1 ;;
    s) SPECFILE=$OPTARG ;;
    h) syntax; exit 0 ;;
    *) syntax; exit $ERR_SYNTAX ;;
  esac
done

#--=[ Mount/Unmount the Android device ]=--
# $1: serial
# $2: device codename
function mountdroid() {
  mount | grep "$MOUNTBASE" >/dev/null
  [[ $? -eq 0 ]] && errExit "Specified MountBase '${MOUNTBASE}' already in use, might be a different device; aborting." $ERR_MOUNT_BUSY
  stdout "$2 Mounting $1 on ${MOUNTBASE}"
  export ANDROID_SERIAL=$1
  adbfs "${MOUNTBASE}" >/dev/null 2>&1
  return $?
}
function umountdroid() {
  stdout "$2 Unmounting $1"
  fusermount -u "${MOUNTBASE}" >/dev/null 2>&1
  [[ $? -ne 0 ]] && {
    stdout "$2 Unmount failed, cool down and retry"
    sleep 2
    fusermount -u "${MOUNTBASE}"
  }
}

#--=[ check requirements ]=--
if [[ -z "$SPECFILE" ]]; then   # not provided at command line
  if [[ -e "devices.json" ]]; then
    SPECFILE=devices.json
  elif [[ -e "$HOME/.config/adbsync/devices.json" ]]; then
    SPECFILE="$HOME/.config/adbsync/devices.json"
  else
    syntax
    errExit "Device configuration file not found (was looking for 'devices.json' and '$HOME/.config/adbsync/devices.json')." $ERR_NO_SPECFILE
  fi
elif [[ ! -e "$SPECFILE" ]]; then
  errExit "Specified configuration file '$SPECFILE' was not found." $ERR_NO_SPECFILE
fi
[[ -z "$(which jq)" ]] && errExit "jq binary not found in PATH. On Linux, install the 'jq' package." $ERR_DEP_MISSING
if [[ -z "${LOGFILE}" ]]; then
  [[ -n "$(jqs -r .logfile)" ]] && LOGFILE="$(jqs -r .logfile)"
fi
if [[ -n "${LOGFILE}" && "${LOGFILE}" != "none" && "${LOGFILE}" != "syslog" && ( ( -e "${LOGFILE}" && ! -w "${LOGFILE}" ) || ( ! -e "${LOGFILE}" && ! -w "$(dirname "${LOGFILE}")" ) ) ]]; then
  CLOGFILE="${LOGFILE}"
  LOGFILE=none
  stderr "cannot log to '${CLOGFILE}' as we have no write permission; file logging disabled."
fi
[[ -z "$(which adbfs)" ]] && errExit "adbfs binary not found. You can get adbfs from https://github.com/spion/adbfs-rootless" $ERR_DEP_MISSING
[[ -z "${MOUNTBASE}" && -n "$(jqs -r '.mountbase')" ]] && MOUNTBASE="$(jqs -r '.mountbase')"
[[ -z "${MOUNTBASE}" ]] && errExit "MountBase was not defined. Please define it in your JSON or pass it via '-m'." $ERR_MOUNTBASE_MISSING
[[ ! -d "${MOUNTBASE}" ]] && errExit "MountBase '${MOUNTBASE}' does not exist or is not a directory." $ERR_MOUNTBASE_MISSING


#--=[ Make sure everything is cleaned up on abortion ]=--
trap "errExit 'Cleanup and abort on user request' $ERR_USER_ABORT" SIGINT
trap "errExit 'Cleanup and abort - script was terminated' $ERR_SIGGTERM" SIGTERM

#--=[ sync files TO the Android device ]=--
# As timestamps would be lost via adbfs, we need to work around that
# $1 - source dir on the computer
# $2 - target dir on the Android device (as seen there)
# $3 - device serial
# $4 - additional options to rsync, like --delete
function sync2dev() {
  local src="$1"
  local tgt="$2"
  local serial="$3"
  local rsyncopts="$4"
  local ts
  local sanipath
  # get changed files
  local files=$(IFS=$'\n' rsync -rltDcu --list-only "$src" "$tgt" | awk '{$1="";$2="";$3="";$4="";print $0}' | sed 's/^\s*//g')
  # perform the real sync
  runcmd "rsync -rltDcu $rsyncopts \"${src}\" \"${MOUNTBASE}${tgt}\""
  # adjust timestamps on-device
  for f in ${files[*]}; do
    [[ "$f" = "." ]] && continue
    ts="$(stat "$src/$f" | grep -i modify |awk '{print $2 $3}' |sed 's/:[0-9.]*$//;s/://;s/-//g')"
    sanipath=$(echo "$tgt/$f" | sed 's!//!/!g')
    runcmd "adb -s $serial shell touch -m -t $ts \"$sanipath\""     # touch modification time. We cannot touch creation time unfortunately
  done
}


#--=[ Synchronize directories between connected device & PC ]=--
# $1 - index of the device in the JSON
function syncdirs() {
  local dev=$1
  local devname="$(jqs -r ".devices[$dev].codename")"
  local devserial="$(jqs -r ".devices[$dev].serial")"
  local dirs=$(jqs -r ".devices[$dev].sync|length")
  [[ $dirs -eq 0 ]] && { stdout "${devname} No syncs defined for '${devname}', skipping."; return; }
  [[ $(jqs "[.devices[$dev].sync[].enabled]|max") -eq 0 ]] && { stdout "${devname} All syncs for '${devname}' are disabled, skipping."; return; }
  mountdroid "${devserial}" "${devname}"
  for dirn in $(seq 0 $dirs); do
    [[ $dirn -eq $dirs ]] && break
    devdir="$(jqs -r ".devices[$dev].sync[$dirn].devdir")"
    [[ $(jqs -r ".devices[$dev].sync[$dirn].enabled") -eq 0 ]] && { stdout "${devname} skip disabled sync for '${devdir}'."; continue; }
    pcdir="$(jqs -r ".devices[$dev].sync[$dirn].pcdir")"
    adb -s $(jqs -r ".devices[$dev].serial") shell test -d "$devdir"
    [[ $? -ne 0 ]] && { stderr "${devname} Source directory '${devdir}' does not exist on '${devname}', skipping."; continue; }
    [[ ! -d "$pcdir" ]] && { stderr "${devname} Target directory '${pcdir}' does not exist on '${devname}', skipping"; continue; }
    # direction is bitwise, so we could compare $(( $direction & 1 )) for device=>pc and $(( $direction & 2 )) for pc=>device (3 would be bidir)
    # "rsync -rltD" instead of "-a" as Android/adbfs does not support "-gop" on SDCard; -u to only copy newer files (update)
    # further "-c" (checksum) so the wrong timestamps("now") are not synced back (funnily "touch -c -m -t <datetime> <file>" works ON DEVICE, so maybe it's adbfs) => -rltDcu
    case $(jqs -r ".devices[$dev].sync[$dirn].direction") in
      1) stdout "${devname} Syncing '${devname}:${devdir}' to '${pcdir}'"
         if [[ "$(jqs -r ".devices[$dev].sync[$dirn].delete")" = "1" ]]; then
           runcmd "rsync -rltDcu --delete '${MOUNTBASE}${devdir}/' '${pcdir}/'"
         else
           runcmd "rsync -rltDcu '${MOUNTBASE}${devdir}/' '${pcdir}/'"
         fi
         ;;
      2) stdout "${devname} Syncing '${devname}:${devdir}' from '${pcdir}'"
         if [[ "$(jqs -r ".devices[$dev].sync[$dirn].delete")" = "2" ]]; then
           sync2dev "${pcdir}" "${devdir}" "${devserial}" "--delete"
         else
           sync2dev "${pcdir}" "${devdir}" "${devserial}" ""
         fi
         ;;
      3) stdout "${devname} Syncing '${devname}:${devdir}' with '${pcdir}' (bidirectionally)"
         runcmd "rsync -rltDcu \"${MOUNTBASE}${devdir}\" \"${pcdir}\""
         sync2dev "${pcdir}" "${devdir}" "${devserial}"
         ;;
      *) stderr "${devname} Invalid or none sync direction specified for '${devname}:${devdir}', skipping." ;;
    esac
  done
  umountdroid "${devserial}" "${devname}"
}


#--=[ get the number of devices ]=--
if [[ $DRYRUN -eq 0 ]]; then
  stdout "======="
  stdout "adbsync"
  stdout "======="
else
  stdout "================"
  stdout "adbsync (DRYRUN)"
  stdout "================"
fi
stdout "Using SpecFile '$SPECFILE'"
devs=$(jqs '.devices|length')
[[ $devs -eq 0 ]] && errExit "No devices found in '$SPECFILE', exiting." $ERR_NO_SPEC_DEVICES
stdout "Found definitions for $devs devices."


#--=[ MAIN: walk the devices list ]=--
for dev in $(seq 0 $(jqs '.devices|length')); do
  [[ $dev -eq $(jqs '.devices|length') ]] && break;    # we need to start at 0 and end at (num -1)
  serial=$(jqs -r ".devices[$dev].serial")             # -r (raw) removes the double-quotes around the value
  enabled=$(jqs -r ".devices[$dev].enabled")
  devname="$(jqs -r ".devices[$dev].codename")"
  [[ $enabled -eq 0 ]] && {
    stdout "${devname} is disabled, skipping."
    continue
  }
  if [[ -n "$(adb devices | grep -E "${serial}\s+device")" ]]; then     # this device is connected, we can sync etc.
    stdout "${devname} is connected."
    syncdirs=$(jqs ".devices[$dev].sync|length")
    if [[ $syncdirs -eq 0 ]]; then
      stdout "${devname} has no directories listed for sync, skipping."
    else
      stdout "${devname} has ${syncdirs} directories listed for sync."
      syncdirs $dev
    fi
  else
    stdout "${devname} is NOT connected, skipping."
  fi
done
