Group
Extension

WebService-GarminConnect/lib/WebService/GarminConnect.pm

package WebService::GarminConnect;

use 5.006;
use warnings FATAL => 'all';
use strict;
use Carp;
use LWP::UserAgent;
use URI;
use JSON;
use Data::Dumper;
use WWW::OAuth;
use WWW::OAuth::Util qw( form_urldecode );
#use LWP::ConsoleLogger::Everywhere ();

our $VERSION = '1.1.2'; # VERSION

=head1 NAME

WebService::GarminConnect - Access data from Garmin Connect

=head1 VERSION

version 1.1.2

=head1 SYNOPSIS

With WebService::GarminConnect, you can search the activities stored on the
Garmin Connect site.

    use WebService::GarminConnect;

    my $gc = WebService::GarminConnect->new( username => 'myuser',
                                             password => 'password' );
    my @activities = $gc->activities( limit => 20 );
    foreach my $a ( @activities ) {
      my $name = $a->{name};
      ...
    }

=head1 FUNCTIONS

=head2 new( %options )

Creates a new WebService::GarminConnect object. One or more options may be
specified:

=over

=item username

(Required) The Garmin Connect username to use for searches.

=item password

(Required) The user's Garmin Connect password.

=item cache_dir

(Optional) Directory where the user's authentication token will be cached.
If not specified, defaults to $HOME/.cache/webservice-garminconnect.

=item searchurl

(Optional) Override the default search URL for Garmin Connect.

=back

=cut

sub new {
  my $self = shift;
  my %options = @_;

  # Check for mandatory options
  foreach my $required_option ( qw( username password ) ) {
    croak "option \"$required_option\" is required"
      unless defined $options{$required_option};
  }

  return bless {
    username  => $options{username},
    password  => $options{password},
    cache_dir => $options{cache_dir},
    searchurl => $options{searchurl} || 'https://connectapi.garmin.com/activitylist-service/activities/search/activities',
  }, $self;
}

sub _login {
  my $self = shift;

  # Bail out if we're already logged in.
  return if defined $self->{is_logged_in};

  my $ua = LWP::UserAgent->new(agent => 'GCM-iOS-5.7.2.1');
  $ua->cookie_jar( {} );
  push @{ $ua->requests_redirectable }, 'POST';

  # location for saved access token
  my $cache_path = $self->{cache_dir};
  if (!defined $cache_path) {
    $cache_path = (getpwuid($>))[7]."/.cache";
    -d $cache_path || mkdir $cache_path, 0700;
    $cache_path .= "/webservice-garminconnect";
    -d $cache_path || mkdir $cache_path, 0700;
  }
  # untaint
  $self->{username} =~ m/([a-z0-9+\-_.=\?@]+)/i;
  $cache_path .= "/${1}_oauth";

  # try saved access token
  if (open my $cache_fh, '<', $cache_path) {
    (my $access_token = <$cache_fh>) =~ s/\s+//;

    $ua->default_header('Authorization', 'Bearer ' . $access_token);
    $self->{useragent} = $ua;
    $self->{is_logged_in} = 1;

    # simple api call to validate
    eval { $self->profile };
    return unless $@;
  }

  my %sso_embed_params = (
    id          => 'gauth-widget',
    embedWidget => 'true',
    gauthHost   => 'https://sso.garmin.com/sso',
  );
  my $uri = URI->new('https://sso.garmin.com/sso/embed');
  $uri->query_form(%sso_embed_params);
  my $response = $ua->get($uri);
  croak "Can't retrieve /sso/embed: " . $response->status_line
    unless $response->is_success;

  my %signin_params = (
    id                              => 'gauth-widget',
    embedWidget                     => 'true',
    gauthHost                       => 'https://sso.garmin.com/sso/embed',
    service                         => 'https://sso.garmin.com/sso/embed',
    source                          => 'https://sso.garmin.com/sso/embed',
    redirectAfterAccountLoginUrl    => 'https://sso.garmin.com/sso/embed',
    redirectAfterAccountCreationUrl => 'https://sso.garmin.com/sso/embed',
  );
  $uri = URI->new('https://sso.garmin.com/sso/signin');
  $uri->query_form(%signin_params);
  $response = $ua->get($uri);
  croak "Can't retrieve /sso/signin: " . $response->status_line
    unless $response->is_success;
  # get the CSRF token from the response, it's a hidden form field
  my $csrf_token;
  if ($response->decoded_content =~ /name="_csrf"\s+value="(.+?)"/) {
    $csrf_token = $1;
  } else {
    croak "couldn't find CSRF token";
  }

  # submit login form with email and password
  $response = $ua->post($uri, Referer => "$uri", Content => {
    username => $self->{username},
    password => $self->{password},
    embed    => 'true',
    _csrf    => $csrf_token,
  });
  croak "Can't submit login  page: " . $response->status_line
    unless $response->is_success;
  my $title;
  if ($response->decoded_content =~ m:<title>(.+)</title>:) {
    $title = $1;
  } else {
    croak "couldn't find <title> in login response";
  }
  if ($title ne 'Success') {
    croak "expected post-login <title> of \"Success\", not \"$title\"";
  }
  my $ticket;
  if ($response->decoded_content =~ /embed\?ticket=([^"]+)"/) {
    $ticket = $1;
  } else {
    croak "couldn't find ticket in login response";
  }

  # get oauth1 token, these came from https://thegarth.s3.amazonaws.com/oauth_consumer.json
  # and are what the Garmin Connect mobile app uses. Perhaps we should
  # try to fetch these from there at runtime in case they ever change?
  my $oauth = WWW::OAuth->new(
    client_id => "fc3e99d2-118c-44b8-8ae3-03370dde24c0",
    client_secret => "E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF",
  );


  $uri = 'https://connectapi.garmin.com/oauth-service/oauth/' .
         "preauthorized?ticket=$ticket&login-url=" .
         'https://sso.garmin.com/sso/embed&accepts-mfa-tokens=true';
  $ua->add_handler(request_prepare => sub { $oauth->authenticate($_[0]) });
  $response = $ua->get($uri);
  croak "Can't retrieve oauth1 page: " . $response->status_line
    unless $response->is_success;
  my %response_data = @{form_urldecode($response->content)};
  foreach my $key ( qw( oauth_token oauth_token_secret ) ) {
    if (!defined $response_data{$key}) {
      croak "oauth response didn't include \"$key\"";
    }
  }
  $oauth->token($response_data{oauth_token});
  $oauth->token_secret($response_data{oauth_token_secret});

  $uri = 'https://connectapi.garmin.com/oauth-service/oauth/exchange/user/2.0';
  $response = $ua->post($uri);
  croak "Can't retrieve oauth1 page: " . $response->status_line
    unless $response->is_success;
  my $response_data = decode_json($response->content);
  if (!defined $response_data->{access_token}) {
    croak "couldn't find access token in response";
  }

  # make subsequent calls use the access token in the Authorization header
  $ua->remove_handler('request_prepare');
  my $access_token = $response_data->{access_token};
  $ua->default_header('Authorization', 'Bearer ' . $access_token);

  #$uri = 'https://connectapi.garmin.com/activitylist-service/activities/search/activities?limit=20&start=0';
  #$response = $ua->get($uri);
  #croak "Can't retrieve activity search  page: " . $response->status_line
  #  unless $response->is_success;

  # Record our logged-in status so future calls will skip login.
  $self->{useragent} = $ua;
  $self->{is_logged_in} = 1;

  # save access token
  if (open my $cache_fh, '>', $cache_path) {
    chmod 0600, $cache_fh;
    print $cache_fh $access_token, "\n";
    close $cache_fh;
  }
}

sub _api {
  my $self = shift;
  my ($api, %opts) = @_;
  my $json = JSON->new();

  # Ensure we are logged in
  $self->_login();
  my $ua = $self->{useragent};

  my $url = URI->new($self->{searchurl});
	$url->path($api);
  $url->query_form(%opts);

  my $headers = [
    'NK' => 'NT',
    'X-app-ver' => '4.71.1.4',
    'X-lang' => 'en-US',
    'X-Requested-With' => 'XMLHttpRequest',
  ];
  my $request = HTTP::Request->new('GET', $url, $headers);
  my $response = $ua->request($request);
  croak "Can't make $api request: " . $response->status_line
    unless $response->is_success;

  return $json->decode($response->content);
}

=head2 profile

Returns the user's Garmin Connect profile

=cut

sub profile {
  my $self = shift;
  return $self->_api("/userprofile-service/socialProfile");
}

=head2 activities( %search_criteria )

Returns a list of activities matching the requested criteria. If no criteria
are specified, returns all the user's activities. Possible criteria:

=over

=item limit

(Optional) The maximum number of activities to return. If not specified,
all the user's activities will be returned.

=item pagesize

(Optional) The number of activities to return in each call to Garmin
Connect. (One call to this subroutine may call Garmin Connect several
times to retrieve all the requested activities.) Defaults to 50.

=back

=cut

sub activities {
  my $self = shift;
  my %opts = @_;
  my $json = JSON->new();

  # Ensure we are logged in
  $self->_login();
  my $ua = $self->{useragent};

  # We can only fetch a fixed number of activities at a time.
  my @activities;
  my $start = 0;
  my $pagesize = 50;
  if( defined $opts{pagesize} ) {
    if( $opts{pagesize} > 0 && $opts{pagesize} < 50 ) {
      $pagesize = $opts{pagesize};
    }
  }

  # Special case when the limit is smaller than one page.
  if( defined $opts{limit} ) {
    if( $opts{limit} < $pagesize ) {
      $pagesize = $opts{limit};
    }
  }

  my $data = [];
  do {
    # Make a search request
    $data = $self->_api("/activitylist-service/activities/search/activities", start => $start, limit => $pagesize);

    # Add this set of activities to the list.
    foreach my $activity ( @{$data} ) {
      if( defined $opts{limit} ) {
        # add this activity only if we're under the limit
        if( @activities < $opts{limit} ) {
          push @activities, { activity => $activity };
        } else {
          $data = []; # stop retrieving more activities
          last;
	}
      } else {
        push @activities, { activity => $activity };
      }
    }

    # Increment the start offset for the next request.
    $start += $pagesize;

  } while( @{$data} > 0 );

  return @activities;
}

=head1 AUTHOR

Joel Loudermilk, C<< <joel at loudermilk.org> >>

=head1 BUGS

Please report any bugs or feature requests to L<https://github.com/jlouder/garmin-connect-perl/issues>.




=head1 SUPPORT

You can find documentation for this module with the perldoc command.

    perldoc WebService::GarminConnect


You can also look for information at:

=over 4

=item * AnnoCPAN: Annotated CPAN documentation

L<http://annocpan.org/dist/WebService-GarminConnect>

=item * CPAN Ratings

L<http://cpanratings.perl.org/d/WebService-GarminConnect>

=item * Search CPAN

L<http://search.cpan.org/dist/WebService-GarminConnect>

=item * GitHub Repository

L<https://github.com/jlouder/garmin-connect-perl>

=back

=head1 COPYRIGHT & LICENSE

Copyright 2023 Joel Loudermilk.

This program 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.

This program 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 this program.  If not, see L<http://www.gnu.org/licenses/>.

=cut

1; # End of WebService::GarminConnect


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.