FCM Android - Domínio do Problema, Implementação e Testes Com Servidor de Aplicativo [Parte 1]

Investir em Você é Barra de Ouro a R$ 2,00. Cadastre-se e receba grátis conteúdos Android sem precedentes! Você receberá um email de confirmação. Somente depois de confirma-lo é que eu poderei lhe enviar os conteúdos semanais exclusivos. Os artigos em PDF são entregues somente para os inscritos na lista.

Email inválido.
Blog /Android /FCM Android - Domínio do Problema, Implementação e Testes Com Servidor de Aplicativo [Parte 1]

FCM Android - Domínio do Problema, Implementação e Testes Com Servidor de Aplicativo [Parte 1]

Vinícius Thiengo
(11054) (32)
Go-ahead
"O método consciente de tentativa e erro é mais bem-sucedido que o planejamento de um gênio isolado."
Peter Skillman
Prototipagem Android
Capa do curso Prototipagem Profissional de Aplicativos
TítuloAndroid: Prototipagem Profissional de Aplicativos
CategoriasAndroid, Design, Protótipo
AutorVinícius Thiengo
Vídeo aulas186
Tempo15 horas
ExercíciosSim
CertificadoSim
Acessar Curso
Quer aprender a programar para Android? Acesse abaixo o curso gratuito no Blog.
Lendo
TítuloManual de DevOps: como obter agilidade, confiabilidade e segurança em organizações tecnológicas
CategoriaEngenharia de Software
Autor(es)Gene Kim, Jez Humble, John Willis, Patrick Debois
EditoraAlta Books
Edição
Ano2018
Páginas464
Conteúdo Exclusivo
Investir em Você é Barra de Ouro a R$ 2,00. Cadastre-se e receba gratuitamente conteúdos Android sem precedentes!
Email inválido

Tudo bem?

Neste artigo vamos iniciar uma série com conteúdos sobre o Firebase Cloud Messaging (FCM) sendo utilizado junto a um servidor de aplicativos.

Como foi feito anteriormente com o GCM, ao menos desde a última vez que falei sobre ele aqui no Blog, dividi o conteúdo para que não tivéssemos apenas um, maçante.

Vamos trabalhar o FCM em um domínio de Blog, logo, teremos todos os algoritmos Web também sendo apresentados.

Neste primeiro artigo nosso objetivo é permitir que ao ser criado um novo post, no dashboard Web do projeto de exemplo, que este evento de criação acione um serviço de notificação push no back-end para que todos os usuários registrados recebam o informe sobre o novo conteúdo, mesmo aqueles que estiverem com o aplicativo aberto.

Aplicativo Android com o FCM rodando em notificações

Mas antes de iniciarmos com os códigos, vamos a algumas discussões sobre o serviço de notificação push, algo já bem antigo não somente no Android, mas em todos os devices com sistemas operacionais mais modernos.

Caso queira ir direto ao vídeo, acesse a seção Vídeo com a implementação do projeto de exemplo.

Antes de prosseguir, não esqueça de se inscrever 📫na lista de e-mails do Blog para receber, em primeira mão, todos os conteúdos exclusivos sobre desenvolvimento Android.

A seguir os tópicos que estaremos abordando:

O que é e por que notificação push via servidor de aplicativo?

Caso ainda não conheça push messages ou notificações push ou somente notificações, essas são referentes àqueles pequenos ícones que ficam na barra superior de seu device, digo, inicialmente são isso: pequenos ícones na barra superior de seu aparelho com informações úteis sobre os aplicativos proprietários delas.

Para utilizarmos essas notificações no Android, nós developers, temos vários possíveis caminhos, alguns já até explicados em artigo e vídeo aqui no Blog:

Todos são válidos, não há um melhor do que o outro, isso depende do contexto de seu app.

Eu, por exemplo, utilizo o OneSignal como sistema de notificação para a parte Web deste Blog e frequentemente o indico para aplicativos Android quando algum desenvolvedor Android vem até mim com a dúvida sobre qual sistema de notificação utilizar.

Por que isso, utilizar o OneSignal? Com os algoritmos de comunicação com os servidores GCM desenvolvidos diretamente em seu back-end você teria mais controle sobre como utilizar notificação push, não?

Sim, teria. Porém alguns domínios do problema precisam de algo simples e rápido. Voia la! O OneSignal, sem sombra de dúvidas, é o que melhor atende a essas regras de negócio, principalmente pela simplicidade na configuração.

Mas em alguns casos é realmente inteligente utilizar diretamente a API de notificação da plataforma em uso. Onde precisamos de controle total, principalmente porque as notificações serão criadas devido a ações de administradores do sistema ou de usuários deste.

Veja os domínios a seguir:

  • Chat: usuários criam notificações, indiretamente, com o envio de novas mensagens;
  • Serviços de carona e taxi: clientes solicitam o transporte. Solicitações que geram notificações para comunicação entre motoristas e clientes;
  • Blog: administradores do sistema criam postagens e com isso notificações são geradas para informar aos usuários sobre novo conteúdo.

Há muitos outros domínios, mas note que quando há a necessidade de informar ao usuário sobre algo, a funcionalidade de push message é o melhor caminho, digo, o mais trivial e chamativo no mundo mobile.

Digo isso, pois por muito tempo, na era Web, somente tínhamos anúncios e lista de emails para: informar sobre conteúdo, promoção, produto, ...

Caso você esteja conhecendo a tecnologia de push message somente agora, saiba que o Google por muito tempo utilizou o Google Cloud Messaging (GCM) para a funcionalidade de notificação push no Android, mesmo quando utilizando também um servidor de aplicativo.

Um servidor de aplicativo é nada mais nada menos que o servidor Web de sua aplicação, onde a partir dele podemos criar a comunicação com os servidores de push message da plataforma em uso e assim notificar todos (ou somente alguns) os usuários do app.

A seguir o fluxo de um serviço de notificação push sendo acionado por um servidor de aplicativo:

Diagrama do fluxo de um serviço de notificação push

Com o relançamento do Firebase, junto a outros serviços do Google no Google I/O 2016, veio o FCM, hoje a tecnologia recomendada para trabalho com push message no Android.

O GCM ainda funciona e até o momento da construção deste artigo o OneSignal o utilizava, porém esse último muito provavelmente está com os dias contados. Logo, com necessidade de notificação push em seu projeto Android, é com o FCM que deve prosseguir.

Note que os servidores de envio de notificações push do FCM ainda são os mesmos do GCM, isso segundo a documentação. Essa é a razão de o fluxo na imagem anterior ainda ter o GCM sendo utilizado.

Aqui, neste primeiro conteúdo, vamos trabalhar o FCM em um domínio do problema real, isso para facilitar ainda mais o "como utiliza-lo".

Antes de partirmos para os algoritmos iniciais, vale informar que a maior parte da documentação do FCM já está em português, ou seja, não há desculpas para não destrincha-la: Início da documentação FCM com servidor de aplicativo.

Projeto de exemplo, parte Web

A partir daqui vamos aos códigos. Como já informado, nosso domínio do problema é de Blog. Teremos um simples dashboard onde será possível criar, atualizar e deletar posts. Também será possível realizar login e alterar senha e email de login, isso para termos um dashboard o mais próximo possível de um em produção.

Para ter acesso completo ao projeto Web, digo, sem as explicações das seções seguintes, entre no GitHub a seguir: https://github.com/viniciusthiengo/blog-android-app-web. Mesmo obtendo o código pelo GitHub, não deixe de prosseguir com o conteúdo do artigo para poder entender como os algoritmos foram desenvolvidos.

Diferente de outros conteúdos já trabalhados aqui no Blog, dessa vez teremos no lado Web não somente o back-end, mas também o front-end. Deixei ambos de uma maneira que você possa aproveita-los em seus próprios projetos, somente terá de adicionar validação, pois não a vi como necessária neste exemplo.

A seguir um print de um dos formulários do dashboard de administrador do Blog:

Formulário de novo post no dashboard de administrador do BlogVamos prosseguir com a construção inicial do projeto, sem a aplicação do FCM, para, depois do código Android, colocarmos os scripts de notificação push.

Ao final desta primeira parte do projeto Web, teremos a seguinte estrutura:

Estrutura física do projeto no lado Web - PHPStorm IDE

Para a construção do lado Web foram utilizadas as seguintes tecnologias:

  • Apache 2.2.29;
  • PHP 5.6.2;
  • MySQL 5.5.38;
  • PHPStorm 10.0.3.

Com isso podemos prosseguir.

Classes de domínio

Vamos iniciar com a apresentação das classes de domínio para facilitar o entendimento das outras classes e camadas que estaremos utilizando no lado Web.

As classes iniciais do pacote /domain são: Categoria, Post, User e UserSystem.

A seguir a classe mais simples, Categoria:

class Categoria
{
public $id;
public $rotulo;

public function setId($id)
{
$this->id = $id;
}

public function setRotulo($rotulo)
{
$this->rotulo = $rotulo;
}
}

 

Já lhe adianto quais categorias teremos: Para iniciantes; Para intermediários; Para profissionais; Web e Android; e Padrões.

A classe Categoria é utilizada também na classe Post:

class Post
{
public $id;
public $titulo;
public $sumario;
public $uriImagem;
public $categoria;

public function __construct()
{
/*
* CONVERSÃO DE PROPRIEDADES VINDAS DO BANCO DE DADOS.
* ESSAS VÊM EM UM FORMATO DIFERENTE DO ESPERADO.
* */
if( property_exists($this, "uri_imagem") ){
$this->uriImagem = $this->uri_imagem;
}
if( property_exists($this, "id_categoria") ){
$this->setCategoria( $this->id_categoria );
}
}

public function setDados_POST()
{
$this->id = $_POST['id'];
$this->titulo = $_POST['titulo'];
$this->sumario = $_POST['sumario'];
$this->uriImagem = $_POST['uri-imagem'];
$this->setCategoria( $_POST['categoria'] );
}

public function setCategoria( $idCategoria )
{
$this->categoria = new Categoria();
$this->categoria->id = $idCategoria;
}
}

 

Provavelmente você deve ter ficado confuso com o construtor e com o método setDados_POST().

O construtor tem o código necessário para que os nomes de colunas no banco de dados que utilizaremos não interfira no bom funcionamento de nossa classe Post com o padrão de nomes em CamelCase.

Ainda vamos chegar aos códigos SQL, mas sabemos que o trabalho com underline, "_", para nome de banco, tabelas e colunas no SQL é mais aceitável do que o CamelCase, principalmente por essa linguagem não ser case-sensitive.

O método setDados_POST() está sendo utilizado para evitar que tenhamos de colocar o código de atribuição de valor em alguma das classes construtoras, classes de primeiro contato com dados enviados tanto do dashboard de administrador como do aplicativo Android.

Assim podemos prosseguir para a classe UserSystem:

class UserSystem
{
const ID_KEY = 'id';

public $id;
public $email;
public $password;
public $novoPassword;

public function __construct()
{
if( !empty($_SESSION[ self::ID_KEY ]) ){
$this->id = $_SESSION[ self::ID_KEY ];
}
}

public function setDados_POST()
{
$this->email = $_POST['email'];
$this->password = $_POST['password'];
$this->novoPassword = $_POST['novo-password'];
}

public function gerarPasswordHash( $ehNovoPassword=false )
{
if( $ehNovoPassword ){
$this->novoPassword = password_hash(
$this->novoPassword,
PASSWORD_DEFAULT,
['cost'=>12] );
}
else{
$this->password = password_hash(
$this->password,
PASSWORD_DEFAULT,
['cost'=>12] );
}
}
}

 

Essa classe será utilizada para representar o usuário administrador do sistema. Lembra das características de login e mudança de senha e email que comentei anteriormente? Então, essa classe conseguirá manter essas características.

Como a melhor maneira de manter o usuário conectado em um aplicativo Web é utilizando sessão, ao menos quando o back-end é em PHP, no construtor de UserSystem também trabalhamos com a obtenção do identificador único do usuário com uso da superglobal $_SESSION.

Para gerar uma hash de password segura, nós estamos utilizando o método nativo password_hash() onde o primeiro argumento é a senha informada (sem ainda ser processada). O segundo argumento é uma constante indicando qual algoritmo utilizar para gerar a hash de senha e o terceiro argumento é um array associativo com detalhes sobre como deve proceder o algoritmo de hash sinalizado no segundo argumento.

Por fim a última classe de /domain, User:

class User extends UserSystem
{
public $nome;
public $profissao;
public $uriImagem;
public $logged;

public function __construct()
{
parent::__construct();

if( !empty($_POST[ self::ID_KEY ]) ){
$this->id = $_POST[ self::ID_KEY ];
}
else if( property_exists($this, "id_user") ){
/*
* CONVERSÃO DE PROPRIEDADE VINDA DO BANCO DE DADOS.
* ESSA VEM EM UM FORMATO DIFERENTE DO ESPERADO.
* */
$this->id = $this->id_user;
}
}
}

 

Essa classe tem as características para que seja possível o login do usuário no aplicativo Android.

Terá login no app Android?

Sim, para isso vamos utilizar uma API de login que facilita em muito a construção de todo o algoritmo de autenticação no aplicativo. Mas isso deixa para a parte Android, assim que chegarmos a ela comentaremos melhor sobre.

Note que User herda de UserSystem, pois temos em UserSystem alguns atributos e métodos úteis também para os usuários do app Android. Com a herança evitamos repetição de código e, neste caso, não estamos quebrando o entendimento dele, pois essas são classes relacionadas.

Ainda em User, também estamos trabalhando a coleta de dados vindos do banco de dados e da superglobal $_POST, exatamente como fizemos em classes anteriores.

Assim podemos ir ao database que, acredite, está bem simples.

Base de dados (MySQL)

Dessa vez vamos trabalhar com uma base de dados relacional SQL, digo isso, pois nos últimos artigos que utilizamos persistência, para brevidade, trabalhamos com bases JSON.

O MySQL é bem simples e é a base de dados mais utilizada em sistemas Web. Aqui vou apresentar as tabelas e queries de criação destas e da base em si. Não precisaremos, neste projeto de exemplo, de Views e outras características como Stored Procedures.

Temos, a princípio, apenas quatro tabelas: ba_categoria, ba_post, ba_user e ba_user_system. Todas em uma base nomeada blog_android.

Como deve ter notado depois do estudo da seção anterior, as tabelas citadas têm representações em classes de domínio.

Note que você pode utilizar o software que preferir para administrar sua base, até mesmo utilizar uma outra diferente do MySQL.

Para administração eu trabalho com PHPStorm que também permiti isso, além de construção dos algoritmos back-end e front-end. Um outro software que vez ou outra eu utilizo é o MySQLWorkbench, esse último é gratuito.

Antes de prosseguir com os códigos, segue o modelo de entidade-relacionamento da base blog_android:

Modelo de entidade-relacionamento da base blog_android

O diagrama acima foi construído gratuitamente no site https://www.draw.io/.

Assim segue o código SQL para criação do banco de dados:

CREATE DATABASE blog_android;

 

Agora a criação das tabelas, começando pela ba_categoria:

CREATE TABLE `ba_categoria` (
`id` smallint(5) unsigned NOT NULL AUTO_INCREMENT,
`rotulo` varchar(100) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `rotulo` (`rotulo`)
)
ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

 

Então a ba_post:

CREATE TABLE `ba_post` (
`id` smallint(5) unsigned NOT NULL AUTO_INCREMENT,
`titulo` varchar(100) NOT NULL,
`sumario` varchar(200) NOT NULL,
`uri_imagem` varchar(160) NOT NULL,
`id_categoria` smallint(6) NOT NULL,
PRIMARY KEY (`id`)
)
ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

 

Agora a ba_user, para usuários que utilizam o aplicativo Android:

CREATE TABLE `ba_user` (
`id` varchar(200) NOT NULL,
`email` varchar(100) NOT NULL,
`nome` varchar(30) NOT NULL DEFAULT '',
`profissao` varchar(100) NOT NULL DEFAULT '',
`ultimo_login` int(10) unsigned NOT NULL,
`uri_imagem` varchar(160) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`)
)
ENGINE=MyISAM DEFAULT CHARSET=utf8;

 

Note que o id é um varchar, pois a API que utilizaremos como algoritmo de login gera um ID único para o usuário onde o formato String responde melhor do que o Integer, principalmente por não termos controle sobre a geração do ID e por isso não sabemos se "da noite para o dia" esse sistema de autenticação pode começar a gerar IDs únicos também com letras. O que essa API nos garante é: ID único.

Por fim a tabela que permite o acesso ao dashboard, acesso do administrador do sistema, ba_user_system:

CREATE TABLE `ba_user_system` (
`id` smallint(5) unsigned NOT NULL AUTO_INCREMENT,
`email` varchar(100) NOT NULL,
`password` char(60) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`),
UNIQUE KEY `password` (`password`)
)
ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

 

Depois de apresentado todo o DDL inicial, vamos ao código de inserção, digo, inserção de dados de administrador de sistema e de categoria de posts, isso, pois não teremos o formulário de cadastro destes. Esses algoritmos extras ficam como tarefa alternativa para você caso queira continuar com o projeto depois do fim deste artigo.

Lembrando que o não uso desses scripts extras aqui no projeto de exemplo não influencia em nada no entendimento do FCM.

Assim segue código de inserção de dados iniciais em ba_user_system:

INSERT INTO
ba_user_system(
email,
password
)
VALUES(
"seu_endereco_de_email@host.com",
"hash_password_gerada_com_o_metodo_php_password_hash"
);

 

Para gerar a hash de senha e então coloca-la na query de inserção como apresentado anteriormente, rode em algum arquivo PHP de seu projeto o seguinte código:

password_hash(
"sua_senha",
PASSWORD_DEFAULT,
['cost'=>12] );

 

Obtenha o resultado de password_hash() e coloque-o em seu código SQL de inserção.

Agora o código de inserção de dados na tabela ba_categoria:

INSERT INTO
ba_categoria (rotulo)
VALUES
("Para iniciantes"),
("Para intermediários"),
("Para profissionais"),
("Web e Android"),
("Padrões");

 

Note que os códigos SQL apresentados aqui estão todos presentes no pacote /cgd do projeto no GitHub, mais precisamente, no arquivo database.sql.

Camada de gerência de dados

As classes do pacote /cgd são bem simples, as queries são bem intuitivas e pequenas, nenhuma necessitou de JOIN.

Vamos iniciar a apresentação da classe que permite acesso ao objeto de conexão com o banco de dados blog_android. Segue código da classe Database:

class Database {
private $pdo;

public function __destruct()
{
$this->pdo = null;
}

public function getConn()
{
if( is_object($this->pdo) ){
return($this->pdo);
}

try{
$this->pdo = new PDO(
sprintf(
'%s:host=%s;dbname=%s;port=%s;charset=%s',
'mysql',
'127.0.0.1',
'blog_android',
'8889',
'utf8'),
'root',
'root' );
}
catch(PDOException $e){}
return($this->pdo);
}
}

 

Ajuste as configuração de PDO para as de seu servidor, pois provavelmente algumas são diferentes, como a porta 8889, por exemplo.

Vamos utilizar o PDO e não o mysqli, pois no PHP o PDO já faz várias validações para nós, limpando os dados de entrada.

Os dois últimos parâmetros do construtor de PDO são respectivamente os username e senha de acesso ao database.

Agora vamos a classe que trabalha com dados de Post, digo, permite a gerência deles no banco de dados. Segue código de CgdPost:

class CgdPost
{
public function criarPost( Post $post )
{
$query = <<<SQL
INSERT INTO
ba_post(
titulo,
sumario,
uri_imagem,
id_categoria
)
VALUES(
:titulo,
:sumario,
:uri_imagem,
:id_categoria
)
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$statement->bindValue(':titulo', $post->titulo, PDO::PARAM_STR);
$statement->bindValue(':sumario', $post->sumario, PDO::PARAM_STR);
$statement->bindValue(':uri_imagem', $post->uriImagem, PDO::PARAM_STR);
$statement->bindValue(':id_categoria', $post->categoria->id, PDO::PARAM_INT);

$statement->execute();
$database = null;
return( $statement->rowCount() > 0 );
}

public function atualizarPost( Post $post )
{
$query = <<<SQL
UPDATE
ba_post
SET
titulo = :titulo,
sumario = :sumario,
uri_imagem = :uri_imagem,
id_categoria = :id_categoria
WHERE
id = :id
LIMIT 1
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$statement->bindValue(':id', $post->id, PDO::PARAM_INT);
$statement->bindValue(':titulo', $post->titulo, PDO::PARAM_STR);
$statement->bindValue(':sumario', $post->sumario, PDO::PARAM_STR);
$statement->bindValue(':uri_imagem', $post->uriImagem, PDO::PARAM_STR);
$statement->bindValue(':id_categoria', $post->categoria->id, PDO::PARAM_INT);

$statement->execute();
$database = null;
return( $statement->rowCount() > 0 );
}

public function deletarPost( Post $post )
{
$query = <<<SQL
DELETE FROM
ba_post
WHERE
id = :id
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$statement->bindValue(':id', $post->id, PDO::PARAM_INT);

$statement->execute();
$database = null;
return( $statement->rowCount() > 0 );
}

public function retrievePost( Post $post )
{
$query = <<<SQL
SELECT
titulo,
sumario,
uri_imagem,
id_categoria
FROM
ba_post
WHERE
id = :id
LIMIT 1
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$statement->bindValue(':id', $post->id, PDO::PARAM_INT);
$statement->execute();
$database = null;

if( $statement->rowCount() == 1 ){
$data = $statement->fetchObject('Post');
$post->titulo = $data->titulo;
$post->sumario = $data->sumario;
$post->uriImagem = $data->uriImagem;
$post->categoria = $data->categoria;
}
}

public function getPosts()
{
$query = <<<SQL
SELECT
*
FROM
ba_post
ORDER BY
id DESC
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$statement->execute();
$database = null;

$posts = [];
while( ($post = $statement->fetchObject('Post')) !== false ){
$posts[] = $post;
}
return( $posts );
}

public function getCategorias()
{
$query = <<<SQL
SELECT
*
FROM
ba_categoria
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$statement->execute();
$database = null;

$categorias = [];
while( ($categoria = $statement->fetchObject('Categoria')) !== false ){
$categorias[] = $categoria;
}
return( $categorias );
}
}

 

Note que o MySQL foi construído para trabalhar na melhor performance quando logo depois de utilizado a conexão com ele seja cancelada. Com objeto PDO conseguimos esse cancelamento quando deixamos esse objeto de conexão sem referência na memória, como na linha de código a seguir:

...
$database = null;
...

 

Agora, você lembra do código da classe Post, digo, do código do construtor? Segue um trecho dele:

...
if( property_exists($this, "uri_imagem") ){
$this->uriImagem = $this->uri_imagem;
}
...

 

O PHP é uma "mãe". Assim que utilizamos, por exemplo, a linha de código abaixo:

...
$post = $statement->fetchObject('Post')
...

 

A variável $post será preenchida com um objeto do tipo Post, porém os atributos corresponderão aos definidos como nomes de colunas das tabelas envolvidas no resultado da query. Por isso o código de conversão, colocando $this->uri_imagem em $this->uriImagem.

Uma outra maneira de conseguir esse efeito, digo, manter os nomes dos atributos de classe sem necessitar da estratégia de conversão no construtor, é utilizando o as nos resultados dos SELECT.

Assim podemos ir a classe de gerência de dados do administrador do sistema. Segue código de CgdAdmin:

class CgdAdmin
{
public function atualizarEmail( UserSystem $userSystem )
{
$query = <<<SQL
UPDATE
ba_user_system
SET
email = :email
WHERE
id = :id
LIMIT 1
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$statement->bindValue(':email', $userSystem->email, PDO::PARAM_STR);
$statement->bindValue(':id', $userSystem->id, PDO::PARAM_INT);

$statement->execute();
$database = null;
return( $statement->rowCount() > 0 );
}

public function atualizarPassword( UserSystem $userSystem )
{
$query = <<<SQL
UPDATE
ba_user_system
SET
password = :password
WHERE
id = :id
LIMIT 1
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$userSystem->gerarPasswordHash( true );
$statement->bindValue(':password', $userSystem->novoPassword, PDO::PARAM_STR);
$statement->bindValue(':id', $userSystem->id, PDO::PARAM_INT);

$statement->execute();
$database = null;
return( $statement->rowCount() > 0 );
}

public function retrieveUserSystem( UserSystem $userSystem )
{
$query = <<<SQL
SELECT
email,
password
FROM
ba_user_system
WHERE
id = :id
LIMIT 1
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$statement->bindValue(':id', $userSystem->id, PDO::PARAM_INT);
$statement->execute();
$database = null;

if( $statement->rowCount() == 1 ){
$data = $statement->fetchObject('User');
$userSystem->email = $data->email;
$userSystem->password = $data->password;
}
}

public function getPassword( UserSystem $userSystem )
{
$campo = empty($userSystem->id) ? 'email' : 'id';
$valor = empty($userSystem->id) ? $userSystem->email : $userSystem->id;

$query = <<<SQL
SELECT
password
FROM
ba_user_system
WHERE
{$campo} = :valor
LIMIT 1
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$statement->bindValue(':valor', $valor, PDO::PARAM_STR);
$statement->execute();
$database = null;
return $statement->fetchColumn(0);
}

public function login( UserSystem $userSystem )
{
$query = <<<SQL
SELECT
id
FROM
ba_user_system
WHERE
email = :email
LIMIT 1
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$statement->bindValue(':email', $userSystem->email, PDO::PARAM_STR);

$statement->execute();
$database = null;
$userSystem->id = $statement->fetchColumn(0);
}
}

 

Simples como a classe anterior, principalmente pela pouca necessidade de lógica de negócio. Note que utilizo sempre variáveis PHP heredoc para construir as queries, isso, pois assim fica fácil, a olho nu, mante-las, porque podemos indentar e quebrar linhas.

Assim podemos ir a última e menor classe de gerência de dados, ao menos inicialmente ela é a menor. Segue código da classe de gerência de acesso dos usuários do aplicativo "Android Blog App", CgdUser:

class CgdUser
{
public function login( User $user )
{
$query = <<<SQL
INSERT INTO
ba_user(
id,
email,
ultimo_login
)
VALUES(
:id,
:email,
:ultimo_login
)
ON DUPLICATE KEY
UPDATE
ultimo_login = :ultimo_login
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$statement->bindValue(':id', $user->id, PDO::PARAM_STR);
$statement->bindValue(':email', $user->email, PDO::PARAM_STR);
$statement->bindValue(':ultimo_login', time(), PDO::PARAM_INT);

$statement->execute();
$database = null;
return $statement->rowCount() > 0;
}

public function retrieveProfileUser( User $user )
{
$query = <<<SQL
SELECT
nome,
profissao,
uri_imagem
FROM
ba_user
WHERE
id LIKE :id
LIMIT 1
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$statement->bindValue(':id', $user->id, PDO::PARAM_STR);

$statement->execute();
$database = null;

if( ($data = $statement->fetchObject()) !== false ){
$user->nome = $data->nome;
$user->profissao = $data->profissao;
$user->uriImagem = $data->uri_imagem;
}
}
}

 

Provavelmente você deve estar confuso quanto a query do método login():

...
INSERT INTO
ba_user(
id,
email,
ultimo_login
)
VALUES(
:id,
:email,
:ultimo_login
)
ON DUPLICATE KEY
UPDATE
ultimo_login = :ultimo_login
...

 

Como estamos obtendo o ID de usuário de uma API de terceiro (Account Kit API), toda vez que houver um login, como não há formulário de cadastro, temos de verificar se o ID já existe, caso não, somente criamos um novo registro. Caso sim, atualizamos o time do último acesso.

Esse time, ultimo_login, poderá ser utilizado em posteriores algoritmos de relatório, aqui no projeto de exemplo não o utilizaremos.

Assim podemos prosseguir às classes que permitem entidades controladoras, ou de visualização, acessarem a camada de gerência de dados.

Camada de lógica de negócio

No pacote /apl temos as classes que intermediam a lógica e acesso a gerência de dados. Todas são bem simples. Vamos iniciar com o código que permite acesso a gerência de objetos Post, classe AplPost:

class AplPost
{
private $cgdPost;

public function __construct()
{
$this->cgdPost = new CgdPost();
}

public function criarPost( Post $post )
{
$resultado = $this->cgdPost->criarPost( $post );
return $resultado ? 1 : 0;
}

public function atualizarPost( Post $post )
{
$resultado = $this->cgdPost->atualizarPost( $post );
return $resultado ? 1 : 0;
}

public function deletarPost( Post $post )
{
$resultado = $this->cgdPost->deletarPost( $post );
return $resultado ? 1 : 0;
}

public function retrievePost( Post $post )
{
$this->cgdPost->retrievePost( $post );
}

public function getPosts()
{
$posts = $this->cgdPost->getPosts();
return $posts;
}

public function getCategorias()
{
$categorias = $this->cgdPost->getCategorias();
return $categorias;
}
}

 

Note o método criarPost() será o que estaremos atualizando quando formos colocar os códigos de notificação push, digo, principalmente este método, pois nessa primeira parte do projeto vamos gerar notificações assim que um novo post for criado pelo administrador do sistema.

Assim podemos ir a classe que permite acesso a dados do administrador do sistema, AplAdmin:

class AplAdmin
{
private $cgdAdmin;

public function __construct()
{
$this->cgdAdmin = new CgdAdmin();
}

public function login( UserSystem $userSystem )
{
$resultado = 0;
$passwordBD = $this->cgdAdmin->getPassword( $userSystem );

if( password_verify( $userSystem->password, $passwordBD ) ){

$this->cgdAdmin->login( $userSystem );
if( !empty( $userSystem->id ) ){
$_SESSION[User::ID_KEY] = $userSystem->id;
$resultado = 1;
}
}
return $resultado;
}

public function atualizarEmail(UserSystem $userSystem )
{
$resultado = 0;
$passwordBD = $this->cgdAdmin->getPassword( $userSystem );

if( password_verify( $userSystem->password, $passwordBD ) ){

$resultado = $this->cgdAdmin->atualizarEmail( $userSystem );
$resultado = $resultado ? 1 : 0;
}
return $resultado;
}

public function atualizarPassword( UserSystem $userSystem )
{
$resultado = 0;
$passwordBD = $this->cgdAdmin->getPassword( $userSystem );

if( password_verify( $userSystem->password, $passwordBD ) ){

$resultado = $this->cgdAdmin->atualizarPassword( $userSystem );
$resultado = $resultado ? 1 : 0;
}
return $resultado;
}

public function retrieveUserSystem( UserSystem $userSystem )
{
$this->cgdAdmin->retrieveUserSystem( $userSystem );
}
}

 

Atenção especial ao método password_verify() que é utilizado nos métodos login()atualizarEmail()atualizarPassword(). É com esse método que conseguimos verificar a validade da senha do usuário, digo, senha criada com o método password_hash().

Importante ressaltar essa característica de verificação de senha, pois diferente do que você poderia pensar, não há validação de password no banco de dados.

Por fim a menor classe da camada de aplicação e lógica, a classe que permite acesso a gerência dos dados de usuários do aplicativo, classe AplUser:

class AplUser
{
private $cgdUser;

public function __construct()
{
$this->cgdUser = new CgdUser();
}

public function login( User $user )
{
$user->logged = $this->cgdUser->login( $user );
$this->cgdUser->retrieveProfileUser( $user );
}
}

 

Não sei se notou, mas como um estilo de codificação meu, quando o método inicia com get isso indica que algo será retornado. Porém quando o método inicia com retrieve isso indica que o objeto passado como argumento terá um ou mais de seus atributos atualizados, ou seja, não há retorno.

Isso é um estilo de codificação pessoal, não uma regra. Para mim fica melhor a leitura posterior de meus códigos.

Camada de controladores

Nesta camada, /ctrl, temos somente arquivos e não classes, mesmo assim todos são simples como as classes já apresentadas até aqui.

Vamos iniciar com o controlador que permite acesso as entidades e formulários de post. Segue código de CtrlPost.php:

require_once('../autoload.php');

/*
* Caso queira encontrar alguns erros em sua aplicação back-end,
* descomente a linha abaixo.
* */
/* ini_set('display_errors', 1); */


/*
* A superglobal GET é para quando estiver realizando testes pelo navegador
* para não precisar configurar toda a APP para simples testes no back-end.
* */
$dados = isset($_POST['metodo']) ? $_POST : $_GET;

header('Content-Type: application/json; charset=utf-8');


if( strcasecmp( $dados['metodo'], 'get-posts' ) == 0 ){
$apl = new AplPost();
$posts = $apl->getPosts();

echo json_encode( $posts );
}

else if( strcasecmp( $dados['metodo'], 'form-criar-post' ) == 0 ){
$apl = new AplPost();
$categorias = $apl->getCategorias();

require_once('../view/form/criar-post.php');
echo json_encode( array('html'=>$html) );
}

else if( strcasecmp( $dados['metodo'], 'criar-post' ) == 0 ){
$post = new Post();
$post->setDados_POST();

$apl = new AplPost();
$resultado = $apl->criarPost( $post );

echo json_encode( array('resultado'=>$resultado) );
}

else if( strcasecmp( $dados['metodo'], 'form-atualizar-post' ) == 0 ){
$apl = new AplPost();
$posts = $apl->getPosts();
$categorias = $apl->getCategorias();

require_once('../view/form/atualizar-post.php');
echo json_encode( array('html'=>$html) );
}

else if( strcasecmp( $dados['metodo'], 'atualizar-post' ) == 0 ){
$post = new Post();
$post->setDados_POST();

$apl = new AplPost();
$resultado = $apl->atualizarPost( $post );

echo json_encode( array('resultado'=>$resultado) );
}

else if( strcasecmp( $dados['metodo'], 'get-dados-post' ) == 0 ){
$post = new Post();
$post->setDados_POST();

$apl = new AplPost();
$apl->retrievePost( $post );

echo json_encode( array('post'=>$post) );
}

else if( strcasecmp( $dados['metodo'], 'deletar-post' ) == 0 ){
$post = new Post();
$post->setDados_POST();

$apl = new AplPost();
$resultado = $apl->deletarPost( $post );

echo json_encode( array('resultado'=>$resultado) );
}

 

O arquivo autoload.php nós estaremos discutindo sobre na seção posterior. Mas lhe adianto que a função dele é simples: carregar as classes que estão sendo utilizadas no algoritmo em execução.

Caso você esteja com problemas em seu ambiente de desenvolvimento, digo, problemas com os códigos de back-end, descomente a linha ini_set('display_errors', 1) e realize invocações no navegador Web utilizando variáveis GET, invocações similares as realizadas via POST.

Com isso você terá o print em tela dos problemas em seu back-end PHP.

Note que em um software que vai a produção você teria a validação do usuário que está realizando requisições a CtrlPost, isso, pois este arquivo é de acesso somente a usuários autenticados.

Os arquivos que estão sendo carregados nos require_once() dos condicionais serão explicados em seções posteriores. Todos são na verdade marcações HTML.

A seguir o arquivo controlador de acesso as entidades do administrador do sistema, CtrlAdmin.php:

session_start();
require_once('../autoload.php');

/*
* Caso queira encontrar alguns erros em sua aplicação back-end,
* descomente a linha abaixo.
* */
/* ini_set('display_errors', 1); */


/*
* A superglobal GET é para quando estiver realizando testes pelo navegador
* para não precisar configurar toda a APP para simples testes no back-end.
* */
$dados = isset($_POST['metodo']) ? $_POST : $_GET;
$_POST = $dados; /* Para obter dados dentro dos objetos, somente $_POST é utilizado */

header('Content-Type: application/json; charset=utf-8');


if( strcasecmp( $dados['metodo'], 'login' ) == 0 ){
$userSystem = new UserSystem();
$userSystem->setDados_POST();

$apl = new AplAdmin();
$resultado = $apl->login( $userSystem );

echo json_encode( array('resultado'=>$resultado) );
}

else if( strcasecmp( $dados['metodo'], 'sair' ) == 0 ){
unset($_SESSION[User::ID_KEY]);
session_destroy();

echo json_encode( array('resultado'=>1) );
}

else if( strcasecmp( $dados['metodo'], 'form-atualizar-email-login' ) == 0 ){
$userSystem = new UserSystem();

$apl = new AplAdmin();
$apl->retrieveUserSystem( $userSystem );

require_once('../view/form/atualizar-email-login.php');
echo json_encode( array('html'=>$html) );
}

else if( strcasecmp( $dados['metodo'], 'atualizar-email-login' ) == 0 ){
$userSystem = new UserSystem();
$userSystem->setDados_POST();

$apl = new AplAdmin();
$resultado = $apl->atualizarEmail( $userSystem );

echo json_encode( array('resultado'=>$resultado) );
}

else if( strcasecmp( $dados['metodo'], 'form-atualizar-password-login' ) == 0 ){
require_once('../view/form/atualizar-password-login.php');
echo json_encode( array('html'=>$html) );
}

else if( strcasecmp( $dados['metodo'], 'atualizar-password-login' ) == 0 ){
$userSystem = new UserSystem();
$userSystem->setDados_POST();

$apl = new AplAdmin();
$resultado = $apl->atualizarPassword( $userSystem );

echo json_encode( array('resultado'=>$resultado) );
}

 

Note que em todos os arquivos controladores nossa resposta sempre será em JSON, por isso o uso da linha:

header('Content-Type: application/json; charset=utf-8');

 

Note também que para manter o usuário administrador conectado, como esse somente tem acesso via aplicativo Web, utilizamos a superglobal $_SESSION, digo, ativamos ela pela linha session_start().

Por fim o arquivo controlador de usuários do aplicativo, CtrlUser.php:

require_once('../autoload.php');

/*
* Caso queira encontrar alguns erros em sua aplicação back-end,
* descomente a linha abaixo.
* */
/* ini_set('display_errors', 1); */


/*
* A superglobal GET é para quando estiver realizando testes pelo navegador
* para não precisar configurar toda a APP para simples testes no back-end.
* */
$dados = isset($_POST['metodo']) ? $_POST : $_GET;
$_POST = $dados; /* Para obter dados dentro dos objetos, somente $_POST é utilizado */

header('Content-Type: application/json; charset=utf-8');


if( strcasecmp( $dados['metodo'], 'login' ) == 0 ){
$user = new User();
$user->setDados_POST();

$apl = new AplUser();
$apl->login( $user );

echo json_encode( $user );
}

 

Aqui não precisamos de session_start(), pois o controle de "está logado ou não" será realizado no app mobile.

Arquivo de carregamento de classes (autoload)

Caso você seja também um desenvolvedor PHP o arquivo autoload.php é de seu conhecimento. Com ele não precisamos de vários include() ou require() de classes espalhados pelos códigos PHP. Definimos nele uma rotina que permitirá o carregamento das classes em execução.

Na raiz de nosso projeto Web de exemplo, crie um arquivo autoload.php e coloque o seguinte código:

spl_autoload_register( function( $class ) {
$paths = array(
'./apl/',
'../apl/',
'./domain/',
'../domain/',
'./cgd/',
'../cgd/',
'./ctrl/',
'../ctrl/',
);

for($i = 0, $tamI = count($paths); $i < $tamI; $i++){
if( file_exists( $paths[$i] . $class . '.php' ) ){
require_once( $paths[$i] . $class . '.php');
}
}
});

 

Os métodos PHP, ao menos a maioria, não são verbosos e são autocomentados. Por exemplo: o método file_exists() dispensa explicação sobre o que ele faz.

Marcação HTML da camada da camada de visualização

Os códigos de marcação são todos bem simples e a maioria está presente em variáveis PHP, isso, pois estamos sempre retornando dados JSON.

Vamos iniciar com os arquivos no pacote /view/form. A seguir o código do arquivo login.php:

$html = <<<HTML
<form id="form-login">
<label>
Login
</label>
<br>

<input id="cp-email" type="email" maxlength="100" placeholder="Email" />
<br>
<input id="cp-password" type="password" placeholder="Senha" />
<br><br>

<button type="submit">Entrar</button>
<input id="metodo" type="hidden" value="login" />
</form>
HTML;

 

Novamente uma variável heredoc para facilitar o trabalho com leitura eficiente de código mesmo quando com o tipo String.

O código anterior permite o seguinte formulário:

Formulário de login no dashboard de administrador do Blog de exemplo

Agora o código do formulário de atualização de email do administrador do sistema. Segue atualizar-email-login.php:

$html = <<<HTML
<form id="form-atualizar-email-login">
<label for="cp-password">
Senha atual (para prosseguir com a atualização)
</label>
<input id="cp-password" type="password" />
<br><br>


<label for="cp-email">
Email de login
</label>
<input id="cp-email" type="email" maxlength="100" value="{$userSystem->email}" />


<br><br>
<button type="submit">Atualizar email</button>
<input id="metodo" type="hidden" value="atualizar-email-login" />
</form>
HTML;

 

Com esse código temos acesso ao seguinte formulário no dashboard:

Formulário de atualização de e-mail de login no dashboard de administrador do Blog

Agora vamos ao arquivo do formulário de atualização de senha, atualizar-password-login.php:

$html = <<<HTML
<form id="form-atualizar-email-login">
<label for="cp-password">
Senha atual (para prosseguir com a atualização)
</label>
<input id="cp-password" type="password" />
<br><br>


<label for="cp-novo-password">
Nova senha
</label>
<input id="cp-novo-password" type="password" />


<br><br>
<button type="submit">Atualizar senha</button>
<input id="metodo" type="hidden" value="atualizar-password-login" />
</form>
HTML;

 

Com essa marcação temos acesso ao seguinte formulário:

Formulário de atualização de senha de login no dashboard de administrador do Blog

Antes de partirmos para os formulários de criação e atualização de posts, vamos ao arquivo que contém um código comum a esses, o campos-form-post.php:

$htmlCategorias = '<option value="0">Categoria</option>';
foreach( $categorias as $categoria ){
$htmlCategorias .= "<option value=\"{$categoria->id}\">{$categoria->rotulo}</option>";
}

$htmlCampos = <<<HTML
<label for="cp-titulo">
Título
</label>
<input id="cp-titulo" type="text" maxlength="100" />
<br><br>


<label for="cp-categoria">
Categoria
</label>
<select id="cp-categoria">
{$htmlCategorias}
</select>
<br><br>


<label for="cp-sumario">
Sumário
</label>
<textarea id="cp-sumario" rows="4"></textarea>
<br><br>


<label for="cp-imagem">
URI Imagem (URI Web)
</label>
<input id="cp-imagem" type="text" maxlength="160" />
HTML;

 

Note que não trabalhamos lógica de negócio nesses arquivos de visualização. Isso não é um erro, ter lógica nessa camada, mas evita-la aqui ajuda em muito a leitura posterior do código por você ou por outros desenvolvedores. Isso é separação de conceitos.

Assim vamos ao arquivo do formulário de criação de post, criar-post.php:

/*
* O CÓDIGO ABAIXO É NECESSÁRIO, POIS NO CARREGAMENTO DO
* FORMULÁRIO DE CIRAÇÃO, LOGO QUE O USUÁRIO SE CONECTA,
* O PATH DE ACESSO AO ARQUIVO DO FORMULÁRIO É DIFERENTE
* DE QUANDO O CARREGANDO VIA INVOCAÇÃO AJAX.
* */
if( file_exists('view/form/campos-form-post.php') ){
require_once('view/form/campos-form-post.php');
}
else{
require_once('../view/form/campos-form-post.php');
}


$html = <<<HTML
<form id="form-criar-post">
{$htmlCampos}

<br><br>
<button type="submit">Criar post</button>
<input id="metodo" type="hidden" value="criar-post" />
</form>
HTML;

 

No início do código temos a explicação do porquê do condicional adicional. Foi inevitável o uso de lógica nessa parte, pois notei esse local sendo o melhor para isso, tendo em mente que o algoritmo relacionado é apenas o de carregamento de arquivo.

Com o código anterior temos acesso ao seguinte formulário no dashboard de administrador: 

Formulário de novo artigo no dashboard de administrador do Blog de exemplo

E por fim o último arquivo em /view/form, o arquivo do formulário de atualização de post, atualizar-post.php:

require_once('../view/form/campos-form-post.php');

$htmlPosts = '<option value="0">Post</option>';
foreach( $posts as $post ){
$htmlPosts .= "<option value=\"{$post->id}\">{$post->titulo}</option>";
}


$html = <<<HTML
<form id="form-atualizar-post">
<label for="cp-post">
Selecione o post
</label>
<select id="cp-post">
{$htmlPosts}
</select>
<br><br>

{$htmlCampos}

<br><br>
<button type="submit">Atualizar post</button>
<button type="button" class="delete">Deletar post</button>
<input id="metodo" type="hidden" value="atualizar-post" />
</form>
HTML;

 

O <button> "Deletar post" permite a ação indicada em rótulo. O <select>, junto a um algoritmo Ajax, permitirá que os dados do post selecionado sejam carregados no formulário.

O código anterior permite o acesso ao seguinte formulário: 

Formulário de atualização de post no dashboard de administrador do Blog

Assim podemos prosseguir aos outros dois arquivos de marcação. Começando com o do menu lateral, o arquivo menu.php que está como filho direto do pacote /view:

$menu = <<<HTML
<ul class="menu">
<li>
<a href="#CtrlPost|form-criar-post" title="Criar post" class="checked">
Criar post
</a>
</li>
<li>
<a href="#CtrlPost|form-atualizar-post" title="Atualizar post">
Atualizar post
</a>
</li>
<li>
<a href="#CtrlAdmin|form-atualizar-email-login" title="Atualizar email login">
Atualizar email login
</a>
</li>
<li>
<a href="#CtrlAdmin|form-atualizar-password-login" title="Atualizar senha login">
Atualizar senha login
</a>
</li>
<li>
<a href="#CtrlAdmin|sair" class="sair" title="Sair">
Sair
</a>
</li>
</ul>
HTML;

 

E por fim o arquivo que permite a apresentação de todas as marcações HTML do projeto, o arquivo dashboard.php, também filho direto do package /view:

<!doctype html>
<html lang="pt-br">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="generator" content="HTML-Kit 292">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="rating" content="general">
<meta name="copyright" content="© 2013 - 2017 Thiengo Calopsita">
<meta name="DC.publisher" content="Vinícius Thiengo">
<meta name="author" content="Vinícius Thiengo">
<meta name="Custodian" content="Thiengo Calopsita">
<meta name="robots" content="all">
<meta name="keywords" content="android, blog">
<meta name="description" content="Dashboard do Android Blog App.">

<title>
Android Blog App
</title>

<link type="text/css" rel="stylesheet" href="./view/css/blog-android-app.css" />
<link type="text/css" rel="stylesheet" href="./view/css/mSnackbar.css" />
<link href="https://fonts.googleapis.com/css?family=Timmana" rel="stylesheet">
</head>


<body>
<div id="header">
Android Blog App
</div>

<div id="container">
<?php
echo $menu;
?>

<div class="content">
<?php
echo $html;
?>
</div>
<div class="cl"></div>
</div>

<script type="text/javascript" src="./view/js/jquery.3.2.1.min.js"></script>
<script type="text/javascript" src="./view/js/mSnackbar.min.js"></script>
<script type="text/javascript" src="./view/js/blog-android-app.js"></script>
</body>
</html>

 

Em dashboard.php temos as metatags que você pode alterar de acordo com seu ambiente de desenvolvimento / produção. Temos também as tags de carregamento de estilos CSS e fonte externa, tags <link>. E tags de carregamento de script JavaScript, tags <script>.

Assim podemos ir a apresentação dos códigos de estilo.

Código de estilo, CSS, da camada de visualização

Temos três arquivo CSS sendo carregados. Um foi construído somente para este projeto. Um é para carregamento de fonte externa, mais precisamente, a fonte Timmana, encontrada diretamente no Google Fonts. O outro é de um projeto JavaScript de snackbar, o SnackbarJS.

Vamos apresentar somente os arquivos CSS internos ao projeto, o arquivo de carregamento externo você referencia como estamos fazendo com a tag <link> em dashboard.

...
<link href="https://fonts.googleapis.com/css?family=Timmana" rel="stylesheet">
...

 

Segue código de /css/blog-android-app.css:

body {
margin: 0;
font: normal 1em Arial, sans-serifl;
}

.cl {
clear: both;
}

#header {
padding: 15px 30px 5px;
background: #616161;
color: #fff;
font: normal 2em 'Timmana', sans-serif;
background: url(../img/background.png) top left;
}

#container {
max-width: 800px;
margin: 0 auto;
border: 1px solid #dcdcdc;
border-top: none;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
}

#container .menu {
list-style: none;
width: 25%;
box-sizing: border-box;
float: left;
padding: 0;
margin: 0;
}

#container .menu li {
border-bottom: 1px solid #dcdcdc;
}

#container .menu a {
display: block;
padding: 15px 10px 5px;
text-decoration: none;
color: #000;
cursor: pointer;
font-family: 'Timmana', sans-serif;
}

#container .menu a.checked,
#container .menu a:hover {
background: #dcdcdc;
}

#container .content {
width: 75%;
box-sizing: border-box;
padding: 20px;
float: right;
border-left: 1px solid #dcdcdc;
}

form label {
font-family: 'Timmana', sans-serif;
}

form input,
form textarea,
form select {
display: block;
width: 100%;
box-sizing: border-box;
padding: 8px;
border: 1px solid #dcdcdc;
margin-top: 5px;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
background: #fff;
}

form select {
height: 31px;
font-size: 0.8em;
}

form button {
display: block;
float: right;
padding: 12px 10px 3px;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
color: #fff;
background: #4CAF50;
border: none;
font-size: 1em;
cursor: pointer;
font-family: 'Timmana', sans-serif;
}

form button.delete {
margin-right: 10px;
background: #f84137;
}

form button:hover {
opacity: 0.8;
}

 

Apesar do CSS ser um pouco menos trivial do que o HTML ele ainda é simples de estudar.

Agora o arquivo /css/mSnackbar.css:

#mSnackbarContainer {
position: fixed;
bottom: 0;
right: 20px;
height: 70px;
overflow: hidden;
pointer-events: none
}

#mSnackbarContainer .mSnackbar {
line-height: 22px;
padding: 14px 24px;
background-color: #323232;
color: #fff;
font-size: 14px;
z-index: 100;
min-width: 288px;
max-width: 568px;
-webkit-border-radius: 2px;
-moz-border-radius: 2px;
border-radius: 2px;
margin: 0 auto;
float: right;
clear: both;
margin-bottom: 21px;
margin-top: 71px;
-webkit-animation-duration: .2s;
animation-duration: .2s;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
-webkit-transition: all .2s ease-in-out;
-moz-transition: all .2s ease-in-out;
-o-transition: all .2s ease-in-out;
transition: all .2s ease-in-out
}

@-webkit-keyframes slideIn {
from {
margin-top: 71px
}
to {
margin-top: 0
}
}

@keyframes slideIn {
from {
margin-top: 71px
}
to {
margin-top: 0
}
}

@-webkit-keyframes slideOut {
from {
margin-top: 0
}
to {
margin-top: 71px
}
}

@keyframes slideOut {
from {
margin-top: 0
}
to {
margin-top: 71px
}
}

#mSnackbarContainer .mSnackbar.slideIn {
-webkit-animation-name: slideIn;
animation-name: slideIn
}

#mSnackbarContainer .mSnackbar.slideOut {
-webkit-animation-name: slideOut;
animation-name: slideOut
}

 

Para acesso completo, incluindo testes, ao projeto SnackbarJS, entre na página dele em: https://fezvrasta.github.io/snackbarjs/.

Algoritmos JavaScript (jQuery) da camada de visualização

Temos três arquivos JavaScript sendo carregados em nosso front-end Web. Todos são internos ao projeto, porém somente um foi construído exclusivamente para atender as nossas necessidades aqui. Dois estão minificados: o da library jQuery e o da library SnackbarJS.

Logo, para esses dois minificados, onde não será possível estudar o código, vou somente deixar aqui os links de acesso a eles, links diretamente do GitHub do projeto de exemplo deste artigo. Segue:

Assim podemos seguir ao código JS construído somente para o projeto de exemplo, /js/blog-android-app.js:

/* LOGIN */
$(document).on('submit', '#form-login', function( e ){
e.preventDefault();
var $handler = $(this);
var dados = {
'metodo': $('#metodo').val(),
'email': $('#cp-email').val(),
'password': $('#cp-password').val()
};
var callback = function(data){
if( data.resultado == 1 ){
window.location = './'; /* RECARREGANDO A PÁGINA */
}
else{
buttonLabel( $handler, 'Entrar' );
snackBar( 'Falhou! Tente novamente.' );
}
};

buttonLabel( $handler, 'Verificando...' );
ajaxRequest( 'CtrlAdmin', dados, callback );
});


/* DASHBOARD */
$('ul.menu li a:not(.sair)').click(function( e ){
e.preventDefault();
var $handler = $(this);
var dados, callback, metodo;
var ctrl = $handler.prop('href');

ctrl = ctrl.split('#');
ctrl = ctrl[ ctrl.length - 1 ];
ctrl = ctrl.split('|');
metodo = ctrl[1];
ctrl = ctrl[0];
checkedLink( $handler );

dados = { 'metodo': metodo };
callback = function(data){
$('div.content').html( data.html );
};

ajaxRequest( ctrl, dados, callback );
});

$('ul.menu li a.sair').click(function( e ){
e.preventDefault();
var $handler = $(this);
var dados, callback, metodo;
var ctrl = $handler.prop('href');

ctrl = ctrl.split('#');
ctrl = ctrl[ ctrl.length - 1 ];
ctrl = ctrl.split('|');
metodo = ctrl[1];
ctrl = ctrl[0];
checkedLink( $handler );

dados = { 'metodo': metodo };
callback = function(data){
window.location = './'; /* RECARREGANDO A PÁGINA */
};

ajaxRequest( ctrl, dados, callback );
});


/* ADMIN */
$(document).on('submit', '#form-atualizar-email-login, #form-atualizar-password-login', function( e ){
e.preventDefault();
var $handler = $(this);
var ehAtualizacaoEmail = $('#cp-email').length == 1;
var mensagemFeedback = ehAtualizacaoEmail ? 'Email atualizado com sucesso!' : 'Senha atualizada com sucesso!';
var buttonEndLabel = ehAtualizacaoEmail ? 'Atualizar email' : 'Atualizar senha';

var dados = {
'metodo': $('#metodo').val(),
'password': $('#cp-password').val(),
'novo-password': $('#cp-novo-password').val(),
'email': $('#cp-email').val()
};
var callback = function(data){
buttonLabel( $handler, buttonEndLabel );
if( data.resultado == 1 ){
snackBar( mensagemFeedback );
limparCampos( $handler );
}
else{
snackBar( 'Falhou! Reveja os campos e tente novamente.' );
}
};

buttonLabel( $handler, 'Atualizando...' );
ajaxRequest( 'CtrlAdmin', dados, callback );
});


/* POST */
$(document).on('submit', '#form-criar-post, #form-atualizar-post', function( e ){
e.preventDefault();
var $handler = $(this);
var ehAtualizacao = $('#cp-post').length == 1;
var mensagemFeedback = ehAtualizacao ? 'Post atualizado com sucesso!' : 'Post criado com sucesso!';
var buttonSendingLabel = ehAtualizacao ? 'Atualizando post...' : 'Criando post...';
var buttonEndLabel = ehAtualizacao ? 'Atualizar post' : 'Criar post';

var dados = {
'metodo': $('#metodo').val(),
'id': $('#cp-post').val(),
'titulo': $('#cp-titulo').val(),
'categoria': $('#cp-categoria').val(),
'sumario': $('#cp-sumario').val(),
'uri-imagem': $('#cp-imagem').val()
};
var callback = function(data){
buttonLabel( $handler, buttonEndLabel );
if( data.resultado == 1 ){
snackBar( mensagemFeedback );
if( !ehAtualizacao ){
limparCampos( $handler );
}
}
else{
snackBar( 'Falhou! Reveja os campos e tente novamente.' );
}
};

buttonLabel( $handler, buttonSendingLabel );
ajaxRequest( 'CtrlPost', dados, callback );
});


$(document).on('change', '#cp-post', function(){
var $handler = $(this);
var dados = {
'metodo': 'get-dados-post',
'id': $handler.val()
};
var callback = function(data){
$('#cp-titulo').val( data.post.titulo );
$('#cp-categoria').val( data.post.categoria.id );
$('#cp-sumario').val( data.post.sumario );
$('#cp-imagem').val( data.post.uriImagem );
};

ajaxRequest( 'CtrlPost', dados, callback );
});


$(document).on('click', 'button.delete', function(){
var $handler = $(this);
var dados = {
'metodo': 'deletar-post',
'id': $('#cp-post').val()
};
var callback = function(data){
buttonLabel( $handler, 'Deletar post' );
if( data.resultado == 1 ){
snackBar( 'Post deletado com sucesso!' );
$('ul.menu li a[title="Atualizar post"].checked').trigger('click');
}
else{
snackBar( 'Falhou! Selecione um post e tente novamente.' );
}
};

buttonLabel( $handler, 'Deletando post...' );
ajaxRequest( 'CtrlPost', dados, callback );
});



/* FUNÇÕES */
function ajaxRequest( ctrl, dados, callback ){
$.ajax({
url: 'ctrl/'+ctrl+'.php',
type: 'post',
dataType: 'json',
data: dados
}).done(callback);
}

function buttonLabel( $handler, label ){
var $button;

if( $handler.is('form') ){
$button = $handler.find('button[type="submit"]');
}
else if( !$handler.is('button') ){
$button = $handler.parents('form').find('button[type="submit"]');
}
else {
$button = $handler;
}

$button.prop('title', label);
$button.html(label);
}

function checkedLink( $link ){
$link.parents('ul').find('a.checked').removeClass('checked');
$link.addClass('checked');
}

function limparCampos( $form ){
$form.find('input:not([type="hidden"]), textarea').val('');
$form.find('select').val(0);
}

function snackBar( mensagem ){
$.mSnackbar(mensagem);
setTimeout(function(){
$.mSnackbar().close();
}, 2000);
}

 

Um pouco grande, certo? Mas, acredite, está bem simples. Em maioria somente há códigos de resposta a eventos de formulário.

Caso você seja desenvolvedor JavaScript, sinta-se a vontade em melhorar o código anterior e também em indicar suas melhorias nos comentários do artigo.

Note que devido ao uso do projeto SnackbarJS, assim que realizamos um envio em algum dos formulários do dashboard temos um feedback, como no exemplo a seguir:

SnackbarJS no formulário de novo post

Antes de prosseguir com o último arquivo presente na parte Web, digo, último arquivo ao menos neste início sem código de notificação push. Antes disso saiba que em projetos Web é importante o trabalho de minificação de código front-end, isso para diminuir o tamanho dos arquivos em download pelo navegador e assim conseguir inúmeros benefícios para o site, incluindo um melhor pontuação para o ranqueamento no Google.

Um site de minificação que utilizo frequentemente é o a seguir: https://cssminifier.com/. Nele há links para minificação de algoritmos / marcações de outras linguagens além do CSS.

Arquivo de direcionamento de rota (index)

Nosso arquivo index.php está na raiz do projeto é nele que temos a lógica de qual arquivo front-end apresentar ao usuário. Segue:

session_start();
require_once('autoload.php');


if( !empty( $_SESSION[User::ID_KEY] ) ) {
$apl = new AplPost();
$categorias = $apl->getCategorias();

require_once('view/form/criar-post.php');
require_once('view/menu.php');
require_once('view/dashboard.php');
}
else {
require_once('view/form/login.php');
require_once('view/dashboard.php');
}

 

Caso não esteja logado, vá ao segundo condicional e apresente o formulário de login, caso contrário já apresente o menu e o formulário de criação de posts.

Assim podemos ir a parte Android e logo depois a aplicação do FCM.

Projeto de exemplo, parte Android

A parte Android do projeto estará utilizando o padrão de arquitetura MVP. Como na parte Web, vamos iniciar com os códigos antes da aplicação do FCM.

Para acessar o projeto Android por completo, incluindo as imagens, entre no seguinte GitHub: https://github.com/viniciusthiengo/AndroidBlogApp. De qualquer forma, não deixe de acompanhar todas a explicações dos códigos nas seções seguintes.

Para iniciar, abra o Android Studio e crie um novo projeto escolhendo a opção "Navigation Drawer Activity". O nome dessa app será "Android Blog App".

Ao final desta primeira parte do projeto Android teremos o seguinte aplicativo quando em execução:

Tela principal e menu gaveta do aplicativo Android de Blog

E a seguinte estrutura de projeto:

Estrutura física do app Android no Android Studio

Como deve ter notado no formulário de criação de post na parte Web, não trabalharemos o conteúdo do artigo, somente o thumb, título e sumário. Isso, pois com esses três dados para cada post já conseguimos ter o necessário a nós para trabalho com o FCM no domínio do problema de blog.

Configurações Gradle

A seguir o código inicial do Gradle Project Level, ou build.gradle (Project: AndroidBlogApp):

buildscript {
repositories {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:2.3.1'
}
}

allprojects {
repositories {
jcenter()
}
}

task clean(type: Delete) {
delete rootProject.buildDir
}

 

E então o código inicial do Gradle App Level, ou build.gradle (Module: app):

apply plugin: 'com.android.application'

android {
compileSdkVersion 25
buildToolsVersion "25.0.0"
defaultConfig {
applicationId "br.com.thiengo.androidblogapp"
minSdkVersion 15
targetSdkVersion 25
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}

dependencies {
compile fileTree(dir: 'libs', include: ['*.jar'])
androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
exclude group: 'com.android.support', module: 'support-annotations'
})
compile 'com.android.support:appcompat-v7:25.3.1'
compile 'com.android.support:design:25.3.1'
testCompile 'junit:junit:4.12'

/* CARREGAR IMAGENS REMOTAS */
compile 'com.squareup.picasso:picasso:2.5.2'

/* CONEXÃO REMOTA E PARSER JSON */
compile 'com.loopj.android:android-async-http:1.4.9'
compile 'com.google.code.gson:gson:2.7'

/* REFERENTE AO ACCOUNT KIT */
compile 'com.facebook.android:account-kit-sdk:4.+'
}

 

Todas as libraries adicionais que há no Gradle App Level, digo, além das já iniciadas depois da seleção de um projeto "Navigation Drawer Activity", essas são necessárias para responderem ao domínio do problema de nosso exemplo.

A library Picasso para o carregamento de imagens remotas. As libraries AsyncHttp e Gson para a comunicação com servidor remoto. E a library Facebook para a API de login Account Kit.

Se ainda não conhece o Account Kit API, não deixe de acessar o artigo / vídeo a seguir para saber como trabalhar com essa library. Ela é bem simples e robusta: Como Implementar o Account Kit Login em Seu Aplicativo Android.

Caso você esteja com uma instalação do Gradle mais atual, tanto para o Project Level quanto para o App Level, continue com essa versão mais atual, pois o projeto deverá funcionar sem problemas.

Configurações AndroidManifest

A seguir as configurações do AndroidManifest.xml:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="br.com.thiengo.androidblogapp">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:name=".presenter.App"
android:allowBackup="true"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">

<activity
android:name=".view.LoginActivity"
android:label="@string/app_name"
android:screenOrientation="portrait"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<activity
android:name=".view.PostsActivity"
android:label="@string/app_name"
android:screenOrientation="portrait"
android:theme="@style/AppTheme.NoActionBar" />

<meta-data
android:name="com.facebook.accountkit.ApplicationName"
android:value="@string/app_name" />
<meta-data
android:name="com.facebook.sdk.ApplicationId"
android:value="@string/FACEBOOK_APP_ID" />
<meta-data
android:name="com.facebook.accountkit.ClientToken"
android:value="@string/ACCOUNT_KIT_CLIENT_TOKEN" />

<activity
android:name="com.facebook.accountkit.ui.AccountKitActivity"
android:theme="@style/LoginCustomTheme"
tools:replace="android:theme">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="@string/ak_login_protocol_scheme" />
</intent-filter>
</activity>
</application>

</manifest>

 

O que foi adicionado devido ao uso da library Picasso e da library Account Kit, esses trechos foram destacados.

Configurações de estilo

As configurações de estilo são um pouco longas principalmente devido ao trabalho com a API de login Account Kit, mas ainda são simples de entender.

Vamos iniciar com o código XML de cores, /values/colors.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#9E9E9E</color>
<color name="colorPrimaryDark">#616161</color>
<color name="colorAccent">#4CAF50</color>
<color name="colorPrimaryText">#212121</color>
<color name="colorPrimaryLight">#F5F5F5</color>
</resources>

 

Em seguida o arquivo XML de dimensões, /values/dimens.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>
<dimen name="nav_header_vertical_spacing">16dp</dimen>
<dimen name="nav_header_height">160dp</dimen>
</resources>

 

Então o arquivo de String, incluindo strings das chaves do Account Kit. Segue XML de /values/strings.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Android Blog App</string>

<string name="navigation_drawer_open">Abrir navigation drawer</string>
<string name="navigation_drawer_close">Fechar navigation drawer</string>

<string name="FACEBOOK_APP_ID">SEU_FACEBOOK_ID</string>
<string name="ACCOUNT_KIT_CLIENT_TOKEN">SEU_ACCOUNT_KIT_CLIENT_TOKEN</string>
<string name="ak_login_protocol_scheme">SEU_PROTOCOL_SCHEME</string>
</resources>

 

E por fim o arquivo de definição de estilo em tema, /values/styles.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.AppCompat.Light">
<item name="android:windowBackground">@drawable/background</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>

<style name="AppTheme.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>

<style name="AppTheme.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" />

<style name="AppTheme.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />

<style name="LoginCustomTheme" parent="Theme.AccountKit">
<!-- IMAGEM DE BACKGROUND -->
<item name="com_accountkit_background">@drawable/background</item>
<!-- COR DE BACKGROUND, CASO NÃO DEFINA AO MENOS TRANSPARENTE, A IMAGEM NÃO APARECERÁ -->
<item name="com_accountkit_background_color">#44ffffff</item>
<!-- COR DA TOOLBAR -->
<item name="com_accountkit_primary_color">@color/colorPrimary</item>
<!-- COR DO TEXTO DA TOOLBAR -->
<item name="com_accountkit_primary_text_color">@color/colorPrimaryText</item>
<!-- COR DOS TEXTOS INFORMATIVOS -->
<item name="com_accountkit_secondary_text_color">@color/colorPrimaryText</item>
<!-- COR DA STATUS BAR -->
<item name="com_accountkit_status_bar_color">@color/colorPrimaryDark</item>
<!-- COR DE BACKGROUND DAS VIEWS DE ENTRADA DE DADOS -->
<item name="com_accountkit_input_background_color">@color/colorPrimaryLight</item>
<!-- COR DOS TEXTOS DAS VIEWS DE ENTRADA DE DADOS -->
<item name="com_accountkit_input_text_color">@color/colorPrimaryDark</item>
<!-- COR DE BACKGROUND DOS BUTTONS -->
<item name="com_accountkit_button_background_color">@color/colorPrimaryLight</item>
<!-- COR DOS TEXTOS DOS BUTTONS -->
<item name="com_accountkit_button_text_color">@color/colorPrimaryDark</item>
</style>
</resources>

 

As partes adicionadas ao código comum, essas foram destacadas.

Classes da camada de modelo

Para nossa camada de modelo, inicialmente temos três classes. Vamos iniciar com os códigos da classe que permite o trabalho e primeiro contato com a resposta enviada pelo back-end Web, a classe JsonHttpRequest:

class JsonHttpRequest extends JsonHttpResponseHandler {
public static final String URI = "http://seu_host/blog-android-app/ctrl/";
public static final String METODO_KEY = "metodo";

private PresenterPosts presenterPosts;
private PresenterLogin presenterLogin;


JsonHttpRequest( PresenterPosts presenterPosts){
this.presenterPosts = presenterPosts;
}

JsonHttpRequest( PresenterLogin presenterLogin){
this.presenterLogin = presenterLogin;
}

@Override
public void onStart() {
if( presenterPosts != null ){
presenterPosts.showProgressBar( true );
}
}

@Override
public void onFinish() {
if( presenterPosts != null ){
presenterPosts.showProgressBar( false );
}
}

@Override
public void onSuccess(int statusCode, Header[] headers, JSONArray response) {
Gson gson = new Gson();
ArrayList<Post> posts = new ArrayList<>();
Post p;

for( int i = 0; i < response.length(); i++ ){
try{
p = gson.fromJson( response.getJSONObject( i ).toString(), Post.class );
posts.add( p );
}
catch(JSONException e){}
}
presenterPosts.updateListaRecycler( posts );
}

@Override
public void onSuccess(int statusCode, Header[] headers, JSONObject response) {
Gson gson = new Gson();
User user = gson.fromJson( response.toString(), User.class );
presenterLogin.resultLogin( user );
}
}

 

Em seu_host você pode colocar tanto o localhost como o IP de seu seu localhost ou o IP ou nome de seu domínio público.

Agora vamos a classe de modelo que permite o envio de dados de login do usuário ao back-end Web, ModelLogin:

public class ModelLogin {
private static final String CTRL = "CtrlUser.php";
private static final String ID_KEY = "id";
private static final String EMAIL_KEY = "email";
private static final String METODO_LOGIN = "login";

private AsyncHttpClient asyncHttpClient;
private PresenterLogin presenter;


public ModelLogin(PresenterLogin presenter ){
asyncHttpClient = new AsyncHttpClient();
this.presenter = presenter;
}

public void verifyLogin(User user) {
RequestParams params = new RequestParams();
params.put( JsonHttpRequest.METODO_KEY, METODO_LOGIN );
params.put( ID_KEY, user.getId() );
params.put( EMAIL_KEY, user.getEmail() );

asyncHttpClient.post(
presenter.getContext(),
JsonHttpRequest.URI + CTRL,
params,
new JsonHttpRequest( presenter ));
}
}

 

E por fim a classe que permite requisições ao controlador de posts, ModelPosts:

public class ModelPosts {
private static final String CTRL = "CtrlPost.php";
private static final String METODO_POSTS = "get-posts";

private AsyncHttpClient asyncHttpClient;
private PresenterPosts presenter;


public ModelPosts(PresenterPosts presenter ){
asyncHttpClient = new AsyncHttpClient();
this.presenter = presenter;
}

public void retrievePosts() {
RequestParams params = new RequestParams();
params.put( JsonHttpRequest.METODO_KEY, METODO_POSTS );

asyncHttpClient.post(
presenter.getContext(),
JsonHttpRequest.URI + CTRL,
params,
new JsonHttpRequest( presenter ));
}
}

 

Não coloquei todos os métodos de requisição remota (métodos de ModelLogin e ModelPosts) em apenas uma classe para não colocar mais de uma responsabilidade em uma dessas classe e assim manter a separação de conceitos.

Classes da camada apresentadora

Na camada presenter temos cinco classes. Vamos iniciar com a classe App que tem o código de inicialização do Account Kit API:

public class App extends Application {

@Override
public void onCreate() {
super.onCreate();
AccountKit.initialize( getApplicationContext(), null );
}
}

 

Agora as classes que têm representações no back-end Web. Primeiro a classe de User:

public class User implements Parcelable {
public static final String KEY = "user";

private String id;
private String email;
private String nome;
private String profissao;
private String uriImagem;
private boolean logged;
private String token;


public User(){}
public User( String id, String email ){
this.id = id;
this.email = email;
}

public String getId() {
return id;
}

public void setId(String id) {
this.id = id;
}

public String getEmail() {
return email;
}

public void setEmail(String email) {
this.email = email;
}

public String getNome() {
return nome;
}

public void setNome(String nome) {
this.nome = nome;
}

public String getProfissao() {
return profissao;
}

public void setProfissao(String profissao) {
this.profissao = profissao;
}

public String getUriImagem() {
return uriImagem;
}

public void setUriImagem(String uriImagem) {
this.uriImagem = uriImagem;
}

public boolean isLogged() {
return logged;
}

public void setLogged(boolean logged) {
this.logged = logged;
}

public String getToken() {
return token;
}

public void setToken(String token) {
this.token = token;
}

public boolean ehValidoEnviarToken() {
return id != null && token != null;
}

@Override
public int describeContents() {
return 0;
}

@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(this.id);
dest.writeString(this.email);
dest.writeString(this.nome);
dest.writeString(this.profissao);
dest.writeString(this.uriImagem);
dest.writeByte(this.logged ? (byte) 1 : (byte) 0);
dest.writeString(this.token);
}

protected User(Parcel in) {
this.id = in.readString();
this.email = in.readString();
this.nome = in.readString();
this.profissao = in.readString();
this.uriImagem = in.readString();
this.logged = in.readByte() != 0;
this.token = in.readString();
}

public static final Creator<User> CREATOR = new Creator<User>() {
@Override
public User createFromParcel(Parcel source) {
return new User(source);
}

@Override
public User[] newArray(int size) {
return new User[size];
}
};
}

 

Implementamos o Parcelable, pois um objeto desta classe será enviado de uma atividade a outra via Intent.

Agora a classe Post:

public class Post {
public static final String CATEGORIA_KEY = "categoria";

private long id;
private String titulo;
private String uriImagem;
private String sumario;


public long getId() {
return id;
}

public void setId(long id) {
this.id = id;
}

public String getTitulo() {
return titulo;
}

public void setTitulo(String titulo) {
this.titulo = titulo;
}

public String getUriImagem() {
return uriImagem;
}

public void setUriImagem(String uriImagem) {
this.uriImagem = uriImagem;
}

public String getSumario() {
return sumario;
}

public void setSumario(String sumario) {
this.sumario = sumario;
}
}

 

Todos os métodos getters e setters são necessários na classe Post para o trabalho com a library Gson.

Agora vamos as classes que permitem a comunicação entre camadas, classes PresenterLogin e PresenterPosts.

Iniciando com a PresenterLogin:

public class PresenterLogin {
private ModelLogin model;
private Context context;


public PresenterLogin( Context c ){
context = c;
model = new ModelLogin( this );
}

public Context getContext() {
return context;
}

public void verifyLogin( User user ) {
model.verifyLogin( user );
}

public void resultLogin( User user ) {
if( user.isLogged() ){
Intent it = new Intent( context, PostsActivity.class );
it.putExtra( User.KEY, user );

context.startActivity( it );
((Activity)context).finish();
}
}
}

 

Na classe LoginActivity, onde há a maior parte dos códigos de login do Account Kit, você entenderá como um objeto do tipo User é preenchido e uma instância de PresenterLogin é utilizada.

Por fim a classe PresenterPosts:

public class PresenterPosts {
private User user;
private ModelPosts model;
private Context context;
private ArrayList<Post> posts = new ArrayList<>();


public PresenterPosts( Context c ){
context = c;
user = ((Activity) a).getIntent().getParcelableExtra(User.KEY);
model = new ModelPosts( this );
}

public User getUser(){
return user;
}

public Context getContext() {
return context;
}

public ArrayList<Post> getPosts() {
return posts;
}

public void retrievePosts() {
model.retrievePosts();
}

public void updateListaRecycler(Object object) {
List<Post> postsCarregados = (List<Post>) object;
posts.clear();
posts.addAll( postsCarregados );
((PostsActivity) context).updateListaRecycler();
showProgressBar( !(posts.size() > 0) );
}

public void showProgressBar(boolean status) {
int visibilidade = status ? View.VISIBLE : View.GONE;
((PostsActivity) context).showProgressBar( visibilidade );
}
}

 

Outro código de classe simples, com pouca lógica condicional.

Lembra do objeto User enviado via Intent a partir do método resultLogin() de PresenterLogin? A obtenção deste objeto acontece no construtor de PresenterPosts:

...
public PresenterPosts( Context c ){
...
user = ((Activity) a).getIntent().getParcelableExtra(User.KEY);
...
}
...

 

Assim podemos prosseguir a última camada do projeto.

Classes da camada de visualização

Para a camada de visualização vamos iniciar com os códigos da atividade de login, principalmente porque essa não precisa de um XML de layout.

Segue códigos de LoginActivity:

public class LoginActivity extends AppCompatActivity {
/*
* CÓDIGO INTEIRO ALEATÓRIO PARA POSTERIOR VERIFICAÇÃO
* EM onActivityResult()
* */
public static final int APP_REQUEST_CODE = 665;

private PresenterLogin presenter;


@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

/*
* PARA POSTERIOR ENVIO / VERIFICAÇÃO DE DADOS
* NO BACK-END WEB
* */
presenter = new PresenterLogin( LoginActivity.this );

/* VERIFICA SE O USUÁRIO JÁ ESTÁ CONECTADO */
if (AccountKit.getCurrentAccessToken() != null) {
getUserLoginData();
}
else{
onLoginEmail();
}
}

public void onLoginEmail() {
/*
* DEFINIÇÃO COMPLETA PARA QUE SEJA APRESENTADA
* UMA ACTIVITY DE LOGIN COM SOLICITAÇÃO DE EMAIL
* */
Intent intent = new Intent(this, AccountKitActivity.class);

AccountKitConfiguration
.AccountKitConfigurationBuilder configurationBuilder =
new AccountKitConfiguration
.AccountKitConfigurationBuilder(
LoginType.EMAIL,
AccountKitActivity.ResponseType.TOKEN );

intent.putExtra(
AccountKitActivity.ACCOUNT_KIT_ACTIVITY_CONFIGURATION,
configurationBuilder.build() );

startActivityForResult( intent, APP_REQUEST_CODE );
}

@Override
protected void onActivityResult(
final int requestCode,
final int resultCode,
final Intent data) {
super.onActivityResult(requestCode, resultCode, data);

if (requestCode == APP_REQUEST_CODE) {

/* ACESSANDO O RESULTADO DA ACTIVITY DE LOGIN */
AccountKitLoginResult loginResult = data.getParcelableExtra(
AccountKitLoginResult.RESULT_KEY );

if (loginResult.getError() != null) {
String mensagem = loginResult.getError().getErrorType().getMessage();
Toast.makeText( this, mensagem, Toast.LENGTH_LONG ).show();
}
else {
/*
* TUDO CERTO, VAMOS A OBTENÇÃO DE DADOS DE LOGIN (EMAIL E ID)
* E ASSIM PROSSEGUIR COM O ACESSO A ÁREA DE POSTS
* */
getUserLoginData();
}
}
}

private void getUserLoginData(){
AccountKit.getCurrentAccount(new AccountKitCallback<Account>() {
@Override
public void onSuccess(final Account account) {
User u = new User( account.getId(), account.getEmail() );
presenter.verifyLogin( u );
}

@Override
public void onError(final AccountKitError error) {}
});
}
}

 

O código anterior está repleto de comentários para lhe ajudar ainda mais no entendimento de como trabalhar com a API Account Kit, isso caso você não tenha já estudado o artigo sobre essa API, aqui do Blog, indicado anteriormente.

Note que depois que o login é confirmado, em nosso caso com o envio e confirmação de email, o método getUserLoginData() é invocado para que tenhamos acesso ao email e ID único do usuário.

Esse ID único é gerado pela própria API Account Kit e é confiável, por isso estamos utilizando ele também como ID de usuário em nosso back-end Web.

Assim que o login é realizado com sucesso o usuário tem acesso ao conteúdo original do aplicativo, os posts.

Então vamos a classe adaptadora, digo, ao layout de item que é carregado nela. Segue XML de /res/layout/item_post.xml:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:padding="16dp">

<ImageView
android:id="@+id/iv_thumb"
android:layout_width="70dp"
android:layout_height="70dp"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:scaleType="centerCrop" />

<TextView
android:id="@+id/tv_titulo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_marginBottom="8dp"
android:layout_toEndOf="@+id/iv_thumb"
android:layout_toRightOf="@+id/iv_thumb"
android:textColor="#333"
android:textSize="18sp" />

<TextView
android:id="@+id/tv_sumario"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/tv_titulo"
android:layout_toEndOf="@+id/iv_thumb"
android:layout_toRightOf="@+id/iv_thumb"
android:ellipsize="end"
android:maxLines="3"
android:textSize="14sp" />
</RelativeLayout>

 

A seguir o diagrama do layout anterior:

Diagrama do layout item_post.xml

Agora o código Java de PostsAdapter:

class PostsAdapter extends RecyclerView.Adapter<PostsAdapter.ViewHolder> {
private PostsActivity context;
private List<Post> posts;


class ViewHolder extends RecyclerView.ViewHolder{
ImageView ivThumb;
TextView tvTitulo;
TextView tvSumario;

ViewHolder(View itemView) {
super(itemView);

ivThumb = (ImageView) itemView.findViewById(R.id.iv_thumb);
tvTitulo = (TextView) itemView.findViewById(R.id.tv_titulo);
tvSumario = (TextView) itemView.findViewById(R.id.tv_sumario);
}

private void setData( Post post ){
Picasso.with( context )
.load( post.getUriImagem() )
.into( ivThumb );

tvTitulo.setText( post.getTitulo() );
tvSumario.setText( post.getSumario() );
}
}

PostsAdapter(PostsActivity context, List<Post> posts ){
this.context = context;
this.posts = posts;
}

@Override
public PostsAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View v = LayoutInflater
.from( context )
.inflate(R.layout.item_post, parent, false);

return new ViewHolder(v);
}

@Override
public void onBindViewHolder(ViewHolder holder, int position) {
holder.setData( posts.get( position ) );
}

@Override
public int getItemCount() {
return posts.size();
}
}

 

Código apenas com atribuições de valores.

Assim podemos prosseguir a atividade que tem o RecyclerView de posts, vamos iniciar com os arquivos de layout dela. Primeiro o /res/layout/app_bar_posts.xml:

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="br.com.thiengo.androidblogapp.view.PostsActivity">

<android.support.design.widget.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/AppTheme.AppBarOverlay">

<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
app:popupTheme="@style/AppTheme.PopupOverlay" />

</android.support.design.widget.AppBarLayout>

<RelativeLayout
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
app:layout_behavior="@string/appbar_scrolling_view_behavior">

<android.support.v7.widget.RecyclerView
android:id="@+id/rv_posts"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical" />
</RelativeLayout>

<ProgressBar
android:id="@+id/pb_loading"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
android:visibility="gone" />
</android.support.design.widget.CoordinatorLayout>

 

Segue diagrama de app_bar_posts.xml:

Diagrama do layout app_bar_posts.xml

Agora o layout de cabeçalho que é carregado no NavigationView, /res/layout/nav_header_posts.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="@dimen/nav_header_height"
android:background="@drawable/nav_header_background"
android:gravity="bottom"
android:orientation="vertical"
android:paddingBottom="@dimen/activity_vertical_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:theme="@style/ThemeOverlay.AppCompat.Dark">

<ImageView
android:id="@+id/imageView"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:background="@color/colorPrimary"
android:contentDescription="Imagem de perfil"
android:elevation="4dp" />

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:paddingTop="@dimen/nav_header_vertical_spacing"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="@color/colorPrimaryDark" />

<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:textColor="@android:color/black"
android:textSize="11sp" />
</LinearLayout>

 

Abaixo o diagrama de nav_header_posts.xml:

Diagrama do layout nav_header_posts.xml

Assim o XML de menu que também é carregado no NavigationView, /res/menu/activity_posts_drawer.xml:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

<group android:checkableBehavior="single">
<item
android:checked="true"
android:id="@+id/nav_todos"
android:title="Todos" />
<item
android:id="@+id/nav_iniciantes"
android:title="Para iniciantes" />
<item
android:id="@+id/nav_intermediarios"
android:title="Para intermediários" />
<item
android:id="@+id/nav_profissionais"
android:title="Para profissionais" />
<item
android:id="@+id/nav_web_android"
android:title="Web e Android" />
<item
android:id="@+id/nav_padroes"
android:title="Padrões" />
</group>

<item android:title="Configurações">
<menu>
<item
android:id="@+id/nav_conf_perfil"
android:icon="@drawable/ic_person"
android:title="Perfil" />
</menu>
</item>
</menu>

 

A seguir o diagrama de activity_posts_drawer.xml

Diagrama do menu activity_posts_drawer.xml

E agora o layout principal de PostsActivity, /res/layout/activity_posts.xml:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:openDrawer="start">

<include
layout="@layout/app_bar_posts"
android:layout_width="match_parent"
android:layout_height="match_parent" />

<android.support.design.widget.NavigationView
android:id="@+id/nav_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:background="@android:color/white"
android:fitsSystemWindows="true"
app:headerLayout="@layout/nav_header_posts"
app:itemTextColor="@android:color/black"
app:menu="@menu/activity_posts_drawer" />

</android.support.v4.widget.DrawerLayout>

 

E assim o diagrama do layout principal:

Diagrama do layout activity_posts.xml

Por fim o código Java de PostsActivity:

public class PostsActivity extends AppCompatActivity
implements NavigationView.OnNavigationItemSelectedListener {

private PresenterPosts presenter;
private PostsAdapter adapter;


@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_posts);

Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar( toolbar );
toolbarFontFamily( toolbar );

presenter = new PresenterPosts( this );
initDrawer( toolbar );
initLista();

presenter.retrievePosts();
}

private void toolbarFontFamily(Toolbar toolbar ){
TextView tv = (TextView) toolbar.getChildAt(0);
Typeface font = Typeface.createFromAsset( getAssets(), "Timmana.ttf" );
tv.setTypeface( font );
}

private void initDrawer( Toolbar toolbar ){
DrawerLayout drawer = (DrawerLayout) findViewById(R.id.drawer_layout);
ActionBarDrawerToggle toggle = new ActionBarDrawerToggle(
this, drawer, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close);
drawer.addDrawerListener(toggle);
toggle.syncState();

NavigationView navigation = (NavigationView) findViewById(R.id.nav_view);
navigation.setNavigationItemSelectedListener(this);
setDataDrawerHeaderData( navigation );
}

private void setDataDrawerHeaderData( NavigationView navigation ){
/*
* CLÁUSULA DE GUARDA PARA QUE NÃO ACONTEÇA
* UM NULLPOINTEREXCEPTION
*/
if( presenter.getUser().getUriImagem() == null
|| presenter.getUser().getUriImagem().isEmpty() ){

return;
}

LinearLayout ll = (LinearLayout) navigation.getHeaderView(0);

ImageView ivProfile = (ImageView) ll.getChildAt(0);
TextView tvNome = (TextView) ll.getChildAt(1);
TextView tvProfissao = (TextView) ll.getChildAt(2);

Picasso.with( this )
.load( presenter.getUser().getUriImagem() )
.into( ivProfile );

tvNome.setText( presenter.getUser().getNome() );
tvProfissao.setText( presenter.getUser().getProfissao() );
}

private void initLista() {
super.onStart();

RecyclerView recyclerView = (RecyclerView) findViewById(R.id.rv_posts);
recyclerView.setHasFixedSize(true);

LinearLayoutManager mLayoutManager = new LinearLayoutManager( this );
recyclerView.setLayoutManager(mLayoutManager);

DividerItemDecoration divider = new DividerItemDecoration(
this,
mLayoutManager.getOrientation());
recyclerView.addItemDecoration( divider );

adapter = new PostsAdapter( this, presenter.getPosts() );
recyclerView.setAdapter( adapter );
}

public void updateListaRecycler(){
adapter.notifyDataSetChanged();
}

public void showProgressBar( int visibilidade ){
findViewById(R.id.pb_loading).setVisibility( visibilidade );
}

@SuppressWarnings("StatementWithEmptyBody")
@Override
public boolean onNavigationItemSelected(MenuItem item) {
DrawerLayout drawer = (DrawerLayout) findViewById(R.id.drawer_layout);
drawer.closeDrawer(GravityCompat.START);
return true;
}

@Override
public void onBackPressed() {
DrawerLayout drawer = (DrawerLayout) findViewById(R.id.drawer_layout);
if (drawer.isDrawerOpen(GravityCompat.START)) {
drawer.closeDrawer(GravityCompat.START);
}
else {
super.onBackPressed();
}
}
}

 

Note que o Drawer está aqui, ao menos nessa primeira parte do projeto, como item de design, pois não trabalharemos ele, digo, a ponto de carregar novos posts de acordo com o item de menu selecionado.

Em setDataDrawerHeaderData() estamos acessando alguns dados do objeto User que não são fornecidos pela API de login Account Kit. E é isso mesmo. O que fiz depois do primeiro login no aplicativo foi acessar o banco de dados e colocar nele os dados de imagem, nome e profissão de meu usuário cadastrado.

Vamos recapitular o método retrieveProfileUser() de /cgd/CgdUser:

...
public function retrieveProfileUser( User $user )
{
$query = <<<SQL
SELECT
nome,
profissao,
uri_imagem
FROM
ba_user
WHERE
id LIKE :id
LIMIT 1
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$statement->bindValue(':id', $user->id, PDO::PARAM_STR);

$statement->execute();
$database = null;

if( ($data = $statement->fetchObject()) !== false ){
$user->nome = $data->nome;
$user->profissao = $data->profissao;
$user->uriImagem = $data->uri_imagem;
}
}
...

 

O objeto $user de retrieveProfileUser() é o que será retornado ao app Android depois da invocação a verifylogin() no aplicativo mobile.

Logo, para colocar esses dados de imagem, nome e profissão em no registro correto no banco de dados você pode prosseguir com o desenvolvimento do Fragment / Activity de dados de profile no aplicativo Android ou utilizar uma query SQL similar a seguinte, isso em seu console de banco de dados:

UPDATE
ba_user
SET
nome = "Seu Nome",
profissao = "Sua profissão",
uri_imagem = "uri de alguma imagem sua na Web"
WHERE
id = "Seu ID gerado no Account Kit API e já salvo no BD"
LIMIT 1;

 

Agora, para utilizar uma fonte personalizada como estamos fazendo com a Toolbar de PostsActivity, primeiro, coloque a visualização de seu projeto como "Project" ao invés de "Android":

Visualização de projeto como Project

Logo depois expanda /AndroidBlogApp. Em seguida expanda /app. Assim expanda /src e então expanda /main. Em /main clique com o botão direito do mouse, vá em "New" e logo depois clique em "Directory". Digite assets e clique em "Ok".

Depois utilize um arquivo de fonte em latin e no formato .ttf ou .otf. Coloque esse arquivo dentro do novo diretório /assets.

Em nosso projeto de exemplo vamos utilizar a fonte Timmana. Você pode acessa-la no link a seguir: https://github.com/viniciusthiengo/AndroidBlogApp/blob/master/app/src/main/assets/Timmana.ttf.

Assim podemos partir para a configuração do FCM.

Implementação do Firebase Cloud Messaging (FCM)

Nosso primeiro passo aqui será criar um projeto no dashboard do Firebase, logo depois vamos a configuração dos códigos FCM no aplicativo Android e em paralelo vamos também as configurações no projeto lado Web.

Nosso objetivo é permitir que a notificação seja entregue com o aplicativo fechado ou com o aplicativo aberto. Veja a seguir como será o fluxo de geração de notificação:

Diagrama do fluxo de geração de notificação

Antes de prosseguir. Certifique-se de que está com uma versão do Android Studio maior ou igual a versão 1.5. Certifique-se também que o device de testes tem o Google Play Services instalado (é muito raro não te-lo) e em caso de emulador, certifique-se de que ele tem o Google APIs.

Vamos as implementações.

Criando um projeto Firebase e vinculando-o ao projeto Android

- Crie um novo projeto no console do Firebase: https://console.firebase.google.com/?hl=pt-br.

Caso já tenha um projeto e queira reutiliza-lo, clique em "Importar projeto do Google", caso contrário (nosso caso aqui) clique em "Adicionar projeto":

Dashboard do Firebase

- Preencha as configurações iniciais de um novo projeto Firebase. Aqui vamos colocar como nome de projeto "Android Blog App" e então selecionar em país "Brasil". Em seguida clique em "Criar projeto":

Formulário de novo projeto no dashboard Firebase

- Agora, na próxima página (o dashboard Firebase), clique em "Adicionar o Firebase ao seu aplicativo Android". No primeiro campo coloque o package name de seu projeto. Você consegue acessa-lo no atributo package da tag <manifest> de seu AndroidManifest.xml.

Em "Apelido do aplicativo" coloque novamente o nome do app, "Android Blog App". Como no campo anterior, o campo "Certificado de assinatura de depuração SHA-1" é opcional e não necessário no caso de trabalho com o FCM e servidor de aplicativo. Logo, não vamos preencher esse campo. Clique em "Registrar App":

Formulário de adição de app Android ao Firebase

- No próximo passo clique em "Fazer o download google-services.json" para realizar o download do arquivo de configuração. Em seguida altere a visualização de seu projeto no Android Studio para "Project" e por fim expanda /AndroidBlogApp, logo expanda /app e então copie e cole o arquivo google-services.json dentro de /app:

Adição do arquivo google-services.json ao projeto Android

Assim, clique em "Continuar":

Área de download do arquivo google-services.json, no dashboard Firebase

- No Gradle Project Level, build.gradle (Project: AndroidBlogApp), adicione a seguinte linha em destaque:

buildscript {
repositories {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:2.3.1'
classpath 'com.google.gms:google-services:3.0.0'
}
}
...

 

- Antes de sincronizar o projeto, acesse o Gradle App Level, build.gradle (Module: app), e adicione a seguinte linha em destaque (tem de ser a última linha do Gradle):

...

dependencies {
...
}

apply plugin: 'com.google.gms.google-services'

 

Assim já temos a configuração do plugin dos serviços do Google. Agora é somente sincronizar o projeto.

Para finalizar o passo seis, clique em finalizar no pop-up do dashboard Firebase: 

Passo seis da adição de app Android ao Firebase dashboard

Como resultado você terá um dashboard similar ao seguinte:

Dashboard Firebase com app Android já adicionado

Vamos prosseguir com as configurações.

Configurando os códigos clientes do FCM no aplicativo Android

Primeiro acesse novamente o Gradle App Level, build.gradle (Module: app), e então adicione a seguinte linha em destaque:

...
dependencies {
...

/* FIREBASE */
/* Cloud Messaging / Notifications */
compile 'com.google.firebase:firebase-messaging:9.6.1'
}
...

 

Em seguida sincronize o projeto. Se em seu caso houver uma versão mais atual do que a versão 9.6.1, então utilize essa mais atual.

Agora precisamos do código responsável por obter o token de instância, gerado pelo FCM, para corresponder unicamente a instância de nosso aplicativo no device do usuário.

Para isso precisamos criar uma classe que herda do serviço FirebaseInstanceIdService. No pacote /model de nosso projeto Android de exemplo crie a classe CustomFirebaseInstanceIDService:

public class CustomFirebaseInstanceIDService extends FirebaseInstanceIdService {

@Override
public void onTokenRefresh() {
String refreshedToken = FirebaseInstanceId.getInstance().getToken();
}
}

 

Segundo a documentação, o método onTokenRefresh() somente é invocado quando um novo token é gerado. As ocasiões são as seguintes:

  • Um novo token foi gerado pela primeira vez;
  • Um novo token foi gerado para substituir o anterior.

Ou seja, podemos assumir como seguro que a invocação de FirebaseInstanceId.getInstance().getToken() dentro do método onTokenRefresh() sempre trará um token válido como retorno.

Digo isso, pois podemos invocar o método getToken() em outras partes do aplicativo e caso isso aconteça antes do primeiro trigger de onTokenRefresh() podemos obter um valor null como resposta, isto devido ao token ainda não ter sido gerado.

Lembre que CustomFirebaseInstanceIDService é um serviço, logo, precisamos adiciona-lo também em nosso AndroidManifest.xml:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="br.com.thiengo.androidblogapp">
...

<application
android:name=".presenter.App"
android:allowBackup="true"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
...

<service
android:name=".model.CustomFirebaseInstanceIDService">
<intent-filter>
<action android:name="com.google.firebase.INSTANCE_ID_EVENT"/>
</intent-filter>
</service>

</application>
</manifest>

Configurando o servidor de aplicativo para recebimento de token FCM de usuário

Agora precisamos "preparar o campo" no back-end Web para recebermos o token dos usuários do aplicativo Android para então podermos informa-los sobre um novo conteúdo.

Primeiro, em sua base de dados, crie uma nova tabela. Segue SQL de ba_token:

CREATE TABLE `ba_token` (
`id_user` varchar(20) NOT NULL,
`token` varchar(160) NOT NULL,
PRIMARY KEY (`id_user`,`token`)
)
ENGINE=MyISAM DEFAULT CHARSET=utf8;

 

O id_user será o mesmo que utilizamos já como ID de usuário de aplicativo, o gerado pelo Account Kit API. Logo, sabemos que não poderemos, do Android, enviar somente o token gerado em onTokenRefresh(), teremos de enviar também o ID do usuário para podermos criar o registro.

Mesmo que nessa primeira parte de projeto (primeiro artigo) não tenhamos a necessidade de sabermos sobre os usuários dos tokens, digo, os IDs desses usuários, nas próximas etapas teremos sim de saber identifica-los junto a seus tokens (um usuário poderá ter mais de um token vinculado a ele).

Ok, já temos a nova tabela, agora precisamos dos códigos back-end que vão salvar os dados nessa tabela.

Em /cgd/CgdUser adicione o seguinte método:

...
public function saveToken( User $user )
{
$query = <<<SQL
INSERT INTO
ba_token(
id_user,
token
)
VALUES(
:id_user,
:token
)
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$statement->bindValue(':id_user', $user->id, PDO::PARAM_STR);
$statement->bindValue(':token', $user->token, PDO::PARAM_STR);

$statement->execute();
$database = null;
return $statement->rowCount() > 0;
}
...

 

Agora em /apl/AplUser adicione o método saveToken():

...
public function saveToken( User $user )
{
$resultado = $this->cgdUser->saveToken( $user );
return $resultado ? 1 : 0;
}
...

 

Assim, na classe /domain/User, sobrescreva o método setDados_POST() e adicione o atributo $token:

class User extends UserSystem
{
...
public $token;
...

public function setDados_POST()
{
parent::setDados_POST();
$this->token = $_POST['token'];
}
}

 

Para finalizar esse trecho de recepção de token FCM no back-end Web, no arquivo controlador /ctrl/CtrlUser.php adicione o seguinte trecho de código:

...
else if( strcasecmp( $dados['metodo'], 'registrar-token-notificacao' ) == 0 ){
$user = new User();
$user->setDados_POST();

$apl = new AplUser();
$obj = new stdClass();
$obj->resultado = $apl->saveToken( $user );

echo json_encode( $obj );
}
...

 

Estamos utilizando um objeto do tipo da classe padrão do PHP, stdClass, como retorno para a resposta "resultado", pois caso contrário teríamos de trabalhar, no Android, a sobrecarga de onSuccess() que tem como parâmetro um JSONArray, ou seja, teríamos um baita trabalho somente para acessar um inteiro (1 ou 0).

Com isso precisaremos realizar algumas modificações no código Android, incluindo o condicional para o correto trabalho com o valor de "resultado".

Configurando o aplicativo para envio de token FCM de usuário

Primeiro sabemos que caso o token não tenha sido salvo corretamente no back-end Web teremos de tentar novos envios. Logo, no pacote /model do projeto Android, adicione a classe SPUtil:

public class SPUtil {
private static final String PREF = "pref";
private static final int MODE = Context.MODE_PRIVATE;
private static final String ID_KEY = "id";
private static final String STATUS_KEY = "status";


public static void saveUserId(Context context, User user){
SharedPreferences sp = context.getSharedPreferences( PREF, MODE );
sp.edit().putString( ID_KEY, user.getId() ).apply();
}

public static String getUserId(Context context){
SharedPreferences sp = context.getSharedPreferences( PREF, MODE );
return sp.getString(ID_KEY, null);
}


public static void saveStatusTokenServer(Context context, boolean status){
SharedPreferences sp = context.getSharedPreferences( PREF, MODE );
sp.edit().putBoolean( STATUS_KEY, status ).apply();
}

public static boolean statusTokenServer(Context context){
SharedPreferences sp = context.getSharedPreferences( PREF, MODE );
return sp.getBoolean(STATUS_KEY, false);
}
}

 

Com a classe SPUtil vamos poder salvar o ID único do usuário, ID gerado pelo Account Kit, e então acessa-lo sempre que necessário. Assim facilitando o acesso a esse ID para envia-lo junto ao token ao back-end Web.

Os métodos de status de token vão nos permitir enviar um novo token somente quando um ainda não tenha sido salvo ou enviado ao back-end ou quando um novo token substituto tenha sido gerado.

Ainda no pacote /model, na classe JsonHttpRequest, mais precisamente no método onSuccess(), que tem como um dos parâmetros um objeto JSONObject, adicione o seguinte novo código em destaque:

...
@Override
public void onSuccess(int statusCode, Header[] headers, JSONObject response) {
Gson gson = new Gson();
User user = gson.fromJson( response.toString(), User.class );

try{
/*
* PARA REGISTRO DE TOKEN, CASO O TOKEN TENHA SIDO
* REGISTRADO COM SUCESSO, MARQUE NO SP PARA QUE
* NÃO SEJA NECESSÁRIO UM NOVO ENVIO COM OS MESMOS
* ID E TOKEN
* */
if( response.has("resultado")
&& response.getInt("resultado") == 1 ){

SPUtil.saveStatusTokenServer( presenterLogin.getContext(), true );
}
else if( !response.has("resultado") && user != null ){
presenterLogin.resultLogin( user );
}
}
catch( JSONException e){}
}
...

 

No mesmo pacote, porém agora na classe ModelLogin, adicione os seguintes novos método e constantes:

public class ModelLogin {
...
private static final String TOKEN_KEY = "token";
private static final String METODO_TOKEN = "registrar-token-notificacao";
...

public void sendToken(User user) {
RequestParams params = new RequestParams();
params.put( JsonHttpRequest.METODO_KEY, METODO_TOKEN );
params.put( ID_KEY, user.getId() );
params.put( TOKEN_KEY, user.getToken() );

asyncHttpClient.post(
presenter.getContext(),
JsonHttpRequest.URI + CTRL,
params,
new JsonHttpRequest( presenter ));
}
}

 

Agora no pacote /presenter, na classe User adicione os seguintes métodos e atributo para o token:

public class User implements Parcelable {
...
private String token;
...

public String getToken() {
return token;
}

public void setToken(String token) {
this.token = token;
}

public boolean ehValidoEnviarToken() {
return id != null && token != null;
}
...

@Override
public void writeToParcel(Parcel dest, int flags) {
...
dest.writeString(this.token);
}

protected User(Parcel in) {
...
this.token = in.readString();
}
...
}

 

Como precisamos também do ID único do usuário para assim enviarmos o token, o método ehValidoEnviarToken() somente retorna true se essa condição for verdadeira.

Ainda no pacote /presenter, agora na classe PresenterLogin, adicione o método sendToken():

...
public void sendToken() {
User user = new User();
user.setId( SPUtil.getUserId(context) );
user.setToken( FirebaseInstanceId.getInstance().getToken() );

if( !SPUtil.statusTokenServer(context)
&& user.ehValidoEnviarToken() ){

model.sendToken( user );
}
}
...

 

Com esse método tendo a lógica de negócio correta para a validação "se é ou não possível enviar o token do usuário", podemos invoca-lo mais de uma vez e em qualquer lugar do projeto.

Sim, isso foi um spoiler. Teremos de invocar esse método em mais de um local do aplicativo, isso, pois não temos o controle sobre o tempo de criação do token de instância. Pode ser que ele seja criado antes de termos recuperado o ID de usuário ou somente depois.

Assim vamos as classes que deverão passar por atualizações para a correta invocação de sendToken().

Primeiro, no método onTokenRefresh() em CustomFirebaseInstanceIdService adicione o trecho de código em destaque:

...
@Override
public void onTokenRefresh() {
String refreshedToken = FirebaseInstanceId.getInstance().getToken();

/*
* O TRECHO ABAIXO É RESPONSÁVEL POR PERMITIR QUE
* UM NOVO TOKEN SEJA ENVIADO, OU SEJA, COLOCANDO
* COMO FALSE O VALOR DO STATUS DO TOKEN
* */
SPUtil.saveStatusTokenServer( this, false );

PresenterLogin presenterLogin = new PresenterLogin( this );
presenterLogin.sendToken();
}
...

 

Lembrando que CustomFirebaseInstanceIdService é um serviço e então o uso de this no construtor de PresenterLogin é válido, pois Service também herda de Context.

Mas, temos de ter em mente que a invocação a sendToken() pode levar à conexão remota e consequentemente ao trabalho em uma Thread secundária, ou seja, depois de sendToken() em onTokenRefresh() o contexto do serviço pode não mais ser válido e então a API AsyncHttp não poderá continuar o processamento, gerando uma Exception.

Uma possível solução é trabalhar com o padrão Singleton na classe PresenterLogin. Vamos a atualização do código desta classe:

public class PresenterLogin {
private static PresenterLogin instance;
...

public static PresenterLogin getInstance( Context c ){
if( instance == null ){
instance = new PresenterLogin( c );
}
return instance;
}

public static void clearInstance(){
instance = null;
}

private PresenterLogin( Context c ){
context = c;
model = new ModelLogin( this );
}
...
}

 

O método clearInstance() vai nos permitir remover o contexto sempre que necessário, isso sabendo que o campo estático instance permanece com o objeto referenciado até o fim do processo do aplicativo, proporcionando o conhecido: vazamento de memória.

Lembrando que com o Singleton mantemos o uso do contexto de LoginActivity enquanto ela estiver ativa. Digo, depois de algumas atualizações que ainda precisamos realizar nesta atividade, segue:

public class LoginActivity extends AppCompatActivity {
...

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

/*
* PARA POSTERIOR ENVIO / VERIFICAÇÃO DE DADOS
* NO BACK-END WEB
* */
presenter = PresenterLogin.getInstance( this );

...
}
...

@Override
protected void onDestroy() {
super.onDestroy();
/* PARA EVITAR VAZAMENTO DE MEMÓRIA */
PresenterLogin.clearInstance();
}
}

 

Agora a atualização em onTokenRefresh() em CustomFirebaseInstaceIDService:

...
@Override
public void onTokenRefresh() {
SPUtil.saveStatusTokenServer( this, false );

PresenterLogin presenter = PresenterLogin.getInstance(this);
presenter.sendToken();
}
...

 

Assim podemos prosseguir para o outro local onde devemos invocar o método sendToken(), a atividade PostsActivity. Adicione os códigos em destaque:

public class PostsActivity extends AppCompatActivity
implements NavigationView.OnNavigationItemSelectedListener {
...

@Override
protected void onCreate(Bundle savedInstanceState) {
...

PresenterLogin presenterLogin = PresenterLogin.getInstance(this);
presenterLogin.sendToken();
}
...

@Override
protected void onDestroy() {
super.onDestroy();
/* PARA EVITAR VAZAMENTO DE MEMÓRIA */
PresenterLogin.clearInstance();
}
}

 

Note que quando há a invocação de sendToken() em onTokenRefresh() nós somente temos certeza de que um token válido foi gerado, porém não sabemos se o ID único do usuário já foi recuperado e salvo no SharedPreferences.

Com a invocação de sendToken() no onCreate() de PostsActivity nós somente temos certeza de que temos no SharedPreferences o ID único do usuário.

Porém também sabemos que alguma das duas invocações será 100% válida, a primeira ou a segunda, caso o token já não tenha sido salvo no back-end Web.

Testes e resultados com a nova configuração

Assim podemos realizar os primeiros testes sem ao menos termos colocado a configuração final de envio de notificação push via servidor de aplicativo. Para somente salvar o token no back-end Web essa configuração de envio não é necessária.

Executando o aplicativo, realizando o login e então acessando os dados na tabela ba_token, temos:

Dados de acesso de usuário na tabela ba_token

Configurando o servidor de aplicativo para envio de notificação push

Para envio de notificação precisamos de ainda mais configurações em nosso back-end Web. Estaremos trabalhando com mensagens descendentes, ou seja, nosso servidor de aplicativos é que acionará os servidores do Google para envio de mensagens push para os devices.

A opção de servidor de somente envio de mensagens descendentes também é conhecida como configuração de servidor HTTP.

A outra opção é a configuração de servidor com protocolo XMPP, onde o device também pode utilizar o mesmo canal FCM para enviar mensagens para o servidor de aplicativo, ou seja, mensagens ascendentes.

Note que assim que enviamos uma notificação push para um determinado token / instância, temos como resposta se o envio ocorreu ou não e até mesmo se aquele token utilizado é ainda válido ou se já tem um novo que deve substituí-lo.

Temos dois níveis de feedback em um envio de notificação push:

  • Um é o feedback geral, se a notificação foi enviada com sucesso aos servidores do Google. O ID daquela push message. O número de mensagens que falharam, digo, mensagens individuais aos tokens informados. O número de canonical IDs e o número de mensagens enviadas com sucesso;
  • O outro é o feedback a nível de token, onde é possível saber se houve o envio ou não àquele token e se houve algum problema, esse vem descrito no feedback, podendo ter um token substituto.

Eu poderia continuar falando sobre cada opção possível em uma notificação push, incluindo o que é carga útil, os valores possíveis quando utilizando o atributo notification, ou os valores possíveis quando utilizando o atributo data.

O que escolhi foi utilizar algumas características em nosso projeto e então explica-las, isso, pois a documentação do FCM com servidor de aplicativo é bem ampla, simples e em português.

Logo, para posteriores atualizações e necessidades em seus próprios projetos Android que utilizam o FCM, não deixe de ao menos ler a página do link a seguir sobre a estrutura de uma push notification: Firebase Cloud Messaging Protocolo HTTP.

Prosseguindo com nosso projeto. Para nosso domínio do problema é importante:

  • Enviar notificação push para todos os tokens cadastrados assim que um novo post for criado;
  • Com o feedback a nível de token, caso algum tenha sido removido, digo, o usuário não mais tem o aplicativo instalado, simplesmente remover esse token do banco de dados;
  • Ainda com o feedback a nível de token, caso um novo token tenha sido fornecido para substituir o atual, realizar a atualização imediata em nosso banco de dados.

Vamos iniciar criando uma classe de constantes, pois precisaremos de algumas. No pacote /domain do back-end Web crie a classe Constante:

class Constante
{
const FCM_URL = 'https://fcm.googleapis.com/fcm/send';
const FCM_KEY = 'FCM_KEY_DE_SEU_PROJETO_ANDROID';
const MAX_TOKENS = 1000;
}

 

FCM_URL contém a url que enviaremos uma requisição de notificação push, junto a requisição vão: o dado de autenticação (FCM_KEY), o conteúdo da notificação e os tokens de instâncias que devem recebe-la.

MAX_TOKENS contém o número máximo de tokens que podem estar em uma única requisição de notificação. Ou seja, caso você tenha 4000 tokens válidos registrados e queira informar a todos sobre o novo conteúdo, terá de utilizar um loop para realizar quatro chamadas a FCM_URL com a mesma mensagem, somente mudando os tokens presentes na requisição.

Para acessar o FCM_KEY de seu projeto Android, entre novamente no dashboard Firebase. No menu lateral, no topo dele, clique em "Overview". Logo depois, no box de seu projeto, clique nos três pontos verticais ao lado do package name e então, no menu aberto, clique em "Configurações".

No conteúdo que se abrir, no menu superior, clique em "Cloud Messaging". Em "Credenciais do projeto" copie a hash em "Chave do servidor":

Obtendo, no dashboard Firebase, a Chave de Servidor

Copie a chave do servidor e cole no local de FCM_KEY_DE_SEU_PROJETO_ANDROID em sua classe Constante criada anteriormente.

Assim podemos ir ao desenvolvimento do método que permite obter todos os tokens do banco de dados.

Na classe /cgd/CgdUser crie o método getUsersTokens():

...
public function getUsersTokens( $offsetUser )
{
$maxTokens = Constante::MAX_TOKENS;
$query = <<<SQL
SELECT
id_user,
token
FROM
ba_token
LIMIT {$maxTokens} OFFSET {$offsetUser}
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$statement->execute();
$database = null;

$users = [];
while( ($user = $statement->fetchObject('User')) !== false ){
$users[] = $user;
}
return $users;
}
...

 

Note que apesar de estarmos obtendo até 1000 tokens por cada invocação a getUsersTokens(), podemos sim trabalhar com envios individuais, faremos isso nos próximos artigos sobre o FCM.

Assim podemos ir ao getUsersTokens() da classe de aplicação, adicione o método a classe AplUsers:

...
public function getUsersTokens( $offsetUser )
{
$users = $this->cgdUser->getUsersTokens( $offsetUser );
return $users;
}
... 

 

Agora precisamos criar o código de comunicação com os servidores do Google e também de configuração de notificação push. Esse código é um pouco grande, logo vamos coloca-lo em uma classe exclusiva para isso.

Primeiro, no pacote /apl crie a classe AplNotificacao já com acesso a um objeto do tipo AplUser, pois obteremos os tokens partindo de uma instância desta última classe:

class AplNotificacao
{
private $aplUser;

public function __construct()
{
$this->aplUser = new AplUser();
}
}

 

Podemos realizar uma requisição aos servidores do Google utilizando dados em texto simples ou dados em JSON. Aqui vamos trabalhar com dados em JSON. Logo, nosso próximo passo é criar um método que cria um objeto PHP para representar uma notificação.

Ainda na classe AplNotificacao crie o método getNotificacaoObj():

...
private function getNotificacaoObj( Post $post ){
$obj = new stdClass();

$obj->delay_while_idle = true;

$obj->notification = new stdClass();
$obj->notification->title = $post->titulo;
$obj->notification->body = $post->sumario;
$obj->notification->icon = $post->categoria->getMobIcon();
$obj->notification->color = '#9E9E9E';

return $obj;
}
...

 

Novamente utilizando a classe padrão do PHP, a stdClass.

Ok, mas porque notification deve ser tratado como um objeto também?

Isso é necessário, pois o atributo notification tem a própria carga útil dele, não é somente um único valor como em delay_while_idle. Alias, esse último, o que estamos informando quando colocando-o como true é que a mensagem não deve ser enviada ao device enquanto ele não estiver ativo (fora do modo soneca).

Note que todos os dados que vêm dentro de notification, juntos, não podem ultrapassar o tamanho de 2048 bytes, 2KB. Caso contrário a notificação não é enviada.

E o método getMobIcon() da classe Categoria?

Esse ainda não construímos. Então, uma pausa na explicação dos atributos sendo utilizados no código anterior. Agora vá a classe Categoria em /domain e adicione o seguinte método:

...
public function getMobIcon()
{
switch( $this->id ){
case 1:
return 'ic_categoria_1';
case 2:
return 'ic_categoria_2';
case 3:
return 'ic_categoria_3';
case 4:
return 'ic_categoria_4';
default:
return 'ic_categoria_5';
}
}
...

 

Voltando aos atributos ainda não comentados. Primeiro, estamos utilizando notification, esse aciona algumas ações padrões no device Android, na verdade na API FCM, assim que é entregue ao aparelho, por exemplo: não precisamos nos preocupar em criar um objeto Notification assim que a mensagem chega ao device e ele está ativo, porém o aplicativo não está aberto. A própria API do FCM se encarrega de apresentar a notificação na bandeja do aparelho.

Novamente uma pausa na explicação dos atributos de notification sendo utilizados aqui.

Note que o outro atributo que pode também ser utilizado em conjunto com notification é data. Com data podemos nós mesmos criar nossa notificação com um objeto Notification ou simplesmente realizar processamentos de nosso interesse no background do aplicativo. data tem também limitação de tamanho de carga útil, aqui é 4096 bytes, 4KB.

Em nosso domínio do problema, ao menos até aqui, o uso do atributo notification sozinho é a melhor opção.

Voltando aos atributos somente de notification, seguem explicações dos que estão sendo utilizados:

  • title: é o título que aparecerá na notificação;
  • body: é a mensagem de conteúdo que aparecerá na notificação;
  • icon: é uma String que indica o ícone que deverá ser utilizado como ícone de notificação. Ícone que está interno ao aplicativo. Por exemplo: caso em seus drawable (mdpi, hdpi, xhdpi e xxhdpi) você tenha o ícone ic_casa, coloque como valor de icon "ic_casa";
  • color: a cor de background que será definida para o ícone assim que a versão "big_icon" for necessária (porém não presente, pois somente podemos definir o icon).

Note que no método getNotificacaoObj() ainda não colocamos os tokens que devem receber a push message. Isso virá posteriormente em um algoritmo que trabalhará o envio de 1000 em 1000 tokens.

Com isso podemos ir ao desenvolvimento do método que vai criar um objeto de comunicação com os servidores de push message do Google.

No PHP nós temos a classe nativa curl_init() que permite, além de outras coisas, conexões HTTP.

Assim, ainda na classe AplNotificacao, crie o método getCurlObj():

...
private function getCurlObj( $notification ){
$curl = curl_init();
curl_setopt($curl, CURLOPT_HTTPHEADER, [
'Content-Type:application/json',
'Authorization:key=' . Constante::FCM_KEY
]);
curl_setopt($curl, CURLOPT_URL, Constante::FCM_URL);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_POST, 1);
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($notification));

return $curl;
}
...

 

Note que como parâmetro de entrada estamos recebendo o objeto de notificação criado em getNotificacaoObj(). Algumas configurações do objeto $curl são auto-explicativas, como, por exemplo, o trabalho com os cabeçalhos de autenticação e definição de tipo de dados em requisição:

...
curl_setopt($curl, CURLOPT_HTTPHEADER, [
'Content-Type:application/json',
'Authorization:key=' . Constantes::FCM_KEY
]);
...

 

Esses cabeçalhos, para a correta comunicação com os servidores do Google, devem ser fornecidos, por isso também a importância de não divulgar a sua chave de servidor, aqui o FCM_KEY. Novamente: independente da API de conexão remota que você esteja utilizando, os cabeçalhos acima devem ser fornecidos junto a sua chave de servidor.

Note que caso você precise acessar os cabeçalhos de retorno de requisição para, por exemplo, saber se no contexto geral houve ou não o envio de notificações. Caso precise destes, no PHP cUrl você deve definir também a seguinte configuração:

...
curl_setopt($curl, CURLOPT_HEADER, 1);
...

 

Como em nosso domínio do problema somente queremos o retorno do corpo, indicando no máximo problemas individuais de envios aos tokens informados, devido a isso somente definimos a linha que permite acesso a essa parte do conteúdo de feedback, sem a definição da linha de retorno de cabeçalho:

...
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
...

 

Com o objeto curl_init criado e sabendo que ainda não houve a execução dele e nem mesmo o trabalho com o feedback dos servidores push message do Google, sabendo disso vamos ao desenvolvimento de outro método na classe AplNotificacao, dessa vez o método trabalhandoRequisicaoFCM():

...
private function trabalhandoRequisicaoFCM( $curl, $users ){
$saida = curl_exec( $curl );
curl_close($curl);

$body = json_decode($saida);
$results = $body->results;

for( $i = 0, $tam = count($results); $i < $tam; $i++ ){

if( isset( $results[$i]->registration_id ) ){
/* UM NOVO TOKEN FOI GERADO PARA ESSA INSTÂNCIA */
$this->aplUser->deleteToken( $users[$i] ); /* DELETA O ANTIGO */
$users[$i]->token = $results[$i]->registration_id;
$this->aplUser->saveToken( $users[$i] ); /* SALVA O NOVO */
}

else if( strcasecmp($results[$i]->error, 'NotRegistered') == 0 ){
/*
* TOKEN NÃO MAIS REGISTRADO, DELETAMOS SOMENTE ELE
* E NÃO O USUÁRIO TAMBÉM, ISSO, POIS EM NOSSO DOMÍNIO
* DO PROBLEMA O USUÁRIO NÃO TEM A OPÇÃO DE DELETAR
* CONTA
* */
$this->aplUser->deleteToken( $users[$i] );
}
}
}
...

 

Como a requisição foi no formato JSON, a resposta é também neste formato. Os dados individuais de envio aos tokens estão todos no array results desse feedback do FCM.

Com isso, acessando results, podemos percorrer os resultados individuais e saber se devemos mudar o token atual (primeiro condicional) ou se devemos remove-lo, isso sabendo que o retorno foi "NotRegistered" (segundo condicional). E, obviamente, não fazer nada quando nenhuma das duas condições forem válidas.

Note que a posição do feedback individual é a mesma do token em envio, então seguramente podemos utilizar o nosso contador, $i, no array de tokens (aqui array de usuários) para assim realizar as operações no banco de dados.

Ainda não criamos o método deleteToken(), logo, na classe CgdUser adicione-o:

...
public function deleteToken( User $user )
{
$query = <<<SQL
DELETE FROM
ba_token
WHERE
token LIKE :token
LIMIT 1
SQL;
$database = (new Database($this))->getConn();
$statement = $database->prepare($query);
$statement->bindValue(':token', $user->token, PDO::PARAM_STR);

$statement->execute();
$database = null;
}
...

 

Agora na classe AplUser adicione também o método deleteToken():

...
public function deleteToken( User $user )
{
$this->cgdUser->deleteToken( $user );
}
...

 

Em ambas as versões de deleteToken() não coloquei retorno algum, pois aqui não nos tem funcionalidade o retorno da remoção de um token.

Para finalizar o back-end Web para envio de notificação push, ainda falta, na classe AplNotificacao, o método que junta tudo. Segue sendNotificacaoPush():

...
public function sendNotificacaoPush( Post $post ){
$notification = $this->getNotificacaoObj( $post );

$startUser = 0;
$users = $this->aplUser->getUsersTokens( $startUser );

while( count($users) > 0 ){

$notification->registration_ids = [];
foreach( $users as $user ){
$notification->registration_ids[] = $user->token;
}

$curl = $this->getCurlObj( $notification );
$this->trabalhandoRequisicaoFCM( $curl, $users );

$startUser += Constante::MAX_TOKENS;
$users = $this->aplUser->getUsersTokens( $startUser );
}
}
...

 

Neste método nós obtemos o objeto de notificação, obtemos os tokens de 1000 em 1000 e então realizamos os envios. Estamos utilizando o registration_ids para colocar os tokens que devem receber a notificação.

Caso você precise de um envio único, o recomendado é utilizar o to, onde somente um token poderá ser fornecido.

Agora o que nos resta é alterar o método de criação de post para que caso dê tudo certo, as notificações sejam enviadas.

Em AplPost, mais precisamente no método criarPost(), adicione o seguinte código em destaque:

...
public function criarPost( Post $post )
{
$resultado = $this->cgdPost->criarPost( $post );
$resultado = $resultado ? 1 : 0;

if( $resultado ){
$aplNotificacao = new AplNotificacao();
$aplNotificacao->sendNotificacaoPush( $post );
}

return $resultado;
}
...

 

Assim podemos partir para os testes.

Testes e resultados com o envio de notificação configurado

No dashboard Web, no formulário de criação de notificação, coloque dados para um novo post: 

Formulário de novo post preenchidoClique em "Criar post". Com o device aberto, porém com o aplicativo fechado, quando nem mesmo em segundo plano. Em seguida clique na notificação. Assim temos:

Notificação push sendo entregue no app Android de exemplo

Um problema evidente em nossa atual configuração é que quando o aplicativo está aberto nada acontece, ou seja, nem mesmo a notificação é recebida pelos nossos algoritmos Android. Vamos a mais essa atualização.

Colocando o algoritmo para trabalho de notificação push quando o aplicativo estiver aberto

Nossas novas metas aqui são:

  • Receber a notificação mesmo quando o aplicativo estiver aberto;
  • Obter a carga útil da notificação, quando o aplicativo estiver aberto, e apresentar na lista de posts o novo post;
  • Marcar no menu do NavigationView, quando estivermos abrindo o aplicativo com o clique na notificação, o item que indica a categoria do novo post.

Primeiro, com o FCM configurado em nosso aplicativo, podemos utilizar o serviço FirebaseMessagingService para recebermos as notificações quando o aplicativo estiver aberto, digo, utilizar uma classe que herde deste serviço para podermos fazer isso.

Assim, no projeto Android, mais precisamente no pacote /model, adicione a classe CustomFirebaseMessagingService:

public class CustomFirebaseMessagingService extends FirebaseMessagingService {

@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
/* TODO */
}
}

 

É justamente no método onMessageReceived() que conseguimos acesso aos dados da notificação enviada. Note que se no back-end, no envio dos dados de notificação, utilizarmos o atributo notification, o método onMessageReceived() somente será acionado caso o aplicativo esteja aberto.

Para que o método onMessageReceived() seja acionado até mesmo com o aplicativo fechado, teríamos de utilizar somente o atributo data na push message. Mas em nosso caso, ao menos nesta primeira parte, precisamos também do notification, pois não estamos criando a notificação com um objeto Notification e sim deixando com que a API FCM faça isso para nós.

Quando utilizando em conjunto o notification e o data, caso o aplicativo esteja aberto, ambos são acessíveis pelo método onMessageReceived(). Com o aplicativo fechado, você conseguirá acessar os dados de data pelo getIntent() da atividade acionada depois do clique na notificação.

Antes de prosseguir, devemos adicionar o novo serviço ao AndroidManifest.xml:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="br.com.thiengo.androidblogapp">
...

<application
android:name=".presenter.App"
android:allowBackup="true"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
...

<service
android:name=".model.CustomFirebaseMessagingService">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>

</application>
</manifest>

 

Em nossa nova atualização, para que possamos colocar um novo post na lista de posts do app, isso quando ele estiver aberto, precisaremos que a uri da imagem dele também seja enviada na notificação push, pois ao título e ao sumário nós já temos acesso.

Porém não vamos conseguir isso utilizando somente notification no back-end Web. Logo, vamos utilizar também o atributo data.

Obs. : é possível sim colocar outras chaves e valores no atributo notification além dos pré-definidos, mas para uso de ambos os atributos e para evitar conflitos de chaves em notification, vamos prosseguir utilizando também o data.

Segue atualização do método getNotificacaoObj() da classe AplNotificacao do back-end PHP:

private function getNotificacaoObj( Post $post ){
...
$obj->data = new stdClass();
$obj->data->post = $post;

return $obj;
}

 

Em $post sabemos que temos: título, sumário e uri de imagem. Além isso, com um objeto do tipo Post podemos apenas utilizar o Gson no Android e assim já teremos um objeto pronto para ser adicionado ao RecyclerView.

Antes de partirmos para as atualizações em onMessageReceived() precisamos adicionar um novo método a PostsActivity que permitirá a inclusão de um novo Post no topo do RecyclerView.

Na atividade de posts adicione as seguintes linhas em destaque:

public class PostsActivity extends AppCompatActivity
implements NavigationView.OnNavigationItemSelectedListener {

private PresenterPosts presenter;
private RecyclerView recyclerView;
private PostsAdapter adapter;
public static boolean isOpened;
...

@Override
protected void onStart() {
super.onStart();
isOpened = true;
}

@Override
protected void onStop() {
super.onStop();
isOpened = false;
}

private void initLista() {
...

recyclerView = (RecyclerView) findViewById(R.id.rv_posts);
...
}

public void updateListaRecycler( final int posicao ){
runOnUiThread(new Runnable() {
@Override
public void run() {
adapter.notifyItemInserted( posicao );
recyclerView.scrollToPosition(0);
}
});
}
...
}

 

O atributo estático isOpened é para facilitar a verificação se a PostsActivity está ou não aberta.

O recyclerView ter se tornado uma variável de instância vai facilitar o acesso ao método de scroll dele, algo necessário para o usuário visualizar o novo post.

No método updateListaRecycler() precisamos utilizar o runOnUiThread(), pois a invocação do método onMessageReceiveid(), segundo meus testes, acontece em uma Thread secundária.

Assim precisamos atualizar a classe PresenterPosts, pois ela será a intermediadora entre a classe de modelo, CustomFirebaseMessagingService, e a classe de visualização, PostsActivity, quando houver algum objeto Post envolvido na comunicação.

Em PresenterPosts adicione uma sobrecarga do método updateListaRecycler(), uma com um objeto Post como parâmetro:

...
public void updateListaRecycler(Post post) {
posts.add( 0, post );
((PostsActivity) context).updateListaRecycler( 0 );
}
...

 

Antes de prosseguir, saiba que o método updateListaRecycler() somente poderá ser acionado se o contexto atual de PresenterPosts for a atividade PostsActivity, pois é para esta que estamos aplicando o cast e é ela que contém este método.

Ok, mas o que você quer dizer com isso?

Se notar bem o código de PresenterPosts, verá que no construtor precisamos de um contexto para utilizar um objeto desta classe. A classe CustomFirebaseMessagingService é um serviço e poderíamos sim utilizar uma instância dela como contexto, porém geraríamos uma Exception no uso do método updateListaRecycler(), algo que será necessário quando em onMessageReceived().

O que devemos fazer é novamente utilizar o padrão Singleton, pois assim teremos certeza que caso isOpened seja true, então o contexto em PresenterPosts é sim a instância da atividade PostsActivity.

Então vamos primeiro as atualizações em PresenterPosts:

public class PresenterPosts {
private static PresenterPosts instance;
...

public static PresenterPosts getInstance( Context c ){
if( instance == null ){
instance = new PresenterPosts( c );
}
return instance;
}

public static void clearInstance(){
instance = null;
}
...
}

 

Agora em PostsActivity realizamos as seguintes atualizações (código em destaque):

public class PostsActivity extends AppCompatActivity
implements NavigationView.OnNavigationItemSelectedListener {
...

@Override
protected void onCreate(Bundle savedInstanceState) {
...

presenter = PresenterPosts.getInstance(this);
initDrawer( toolbar );
initLista();
...
}
...

@Override
protected void onDestroy() {
super.onDestroy();
/* PARA EVITAR VAZAMENTO DE MEMÓRIA */
PresenterPosts.clearInstance();
PresenterLogin.clearInstance();
}
}

 

Assim, para finalizar a meta de "obtenção de novo post mesmo quando o aplicativo estiver aberto", ainda precisamos colocar em CustomFirebaseMessagingService o código de leitura de post e envio dele para a atividade de posts. Segue atualização do método onMessageReceived():

...
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
if( PostsActivity.isOpened
&& remoteMessage.getData().size() > 0
&& remoteMessage.getData().containsKey("post") ){

Gson gson = new Gson();
Post p = gson.fromJson( remoteMessage.getData().get("post"), Post.class );

/*
* NÃO TEREMOS PROBLEMAS AQUI COM O CONTEXTO, POIS COMO
* A PostsActivity ESTA NO TOPO DA LISTA DE ACTIVITIES,
* O CONTEXTO DO MESSAGING SERVICE NÃO SERÁ UTILIZADO,
* COM ISSO NÃO CORREMOS O RISCO DE TER MÉTODOS NÃO
* REGISTRADOS NO CONTEXTO SENDO INVOCADOS
* */
PresenterPosts presenter = PresenterPosts.getInstance(this);
presenter.updateListaRecycler( p );
}
}
...

 

Com isso, para concluir nossas tarefas nesta seção, somente precisamos obter a categoria do post em notificação para que quando o aplicativo seja aberto via clique em notification, que o NavigationView tenha a categoria correta marcada.

Para que não seja necessária a leitura e parser de um objeto JSON novamente utilizando a API Gson, vamos em separado enviar também a categoria do post.

No método getNotificacaoObj() da classe AplNotificacao de nosso back-end Web adicione o seguinte código em destaque:

...
private function getNotificacaoObj( Post $post ){
...

$obj->data = new stdClass();
$obj->data->categoria = $post->categoria->id;
$obj->data->post = $post;

return $obj;
}
...

 

Agora, no código Android, antes de partirmos para mais atualizações em PresenterLogin, na classe Post adicione duas nova constantes:

public class Post {
public static final String POST_KEY = "post";
public static final String CATEGORIA_KEY = "categoria";
...
}

 

Essas constantes serão utilizadas para acessarmos o post e o id de categoria que vão estar na push notification, assim evitamos o trabalho com valores literais ou, valores mágicos.

Na classe PresenterLogin, adicione os trechos em destaque:

...
public void resultLogin( User user ) {
if( user.isLogged() ){
SPUtil.saveUserId(context, user );

Intent it = new Intent( context, PostsActivity.class );
it.putExtra( User.KEY, user );
it.putExtra( Post.CATEGORIA_KEY, getCategoria() );

context.startActivity( it );
((Activity)context).finish();
}
}

private int getCategoria() {
Intent it = ((Activity) context).getIntent();

if( it != null
&& it.hasExtra( Post.CATEGORIA_KEY ) ){

return Integer.parseInt( it.getStringExtra( Post.CATEGORIA_KEY ) );
}
return 0;
}
...

 

Colocamos um id inteiro em getNotificacaoObj() e ele mesmo assim é tratado como String no Android?

Sim, essa é outra particularidade que tem de saber sobre o FCM. Caso precise acessar os dados via extra de Intent, acesse eles como String e posteriormente aplique conversões.

Agora, em PostsActivity, precisamos do código que vai marcar corretamente no NavigationView o item que representa a categoria do post em notificação. Segue atualização no método initDrawer():

...
private void initDrawer( Toolbar toolbar ){
...

int itemSelected = getIntent().getIntExtra( Post.CATEGORIA_KEY, 0 );

NavigationView navigation = (NavigationView) findViewById(R.id.nav_view);
navigation.setNavigationItemSelectedListener(this);
navigation.getMenu().getItem( itemSelected ).setChecked(true);
setDataDrawerHeaderData( navigation );
}
...

 

Antes de prosseguirmos, atualize o onMessageReceived() para trabalhar com a constante POST_KEY:

...
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {

if( PostsActivity.isOpened
&& remoteMessage.getData().size() > 0
&& remoteMessage.getData().containsKey( Post.POST_KEY ) ){

Gson gson = new Gson();
Post p = gson.fromJson( remoteMessage.getData().get( Post.POST_KEY ), Post.class );

PresenterPosts presenter = PresenterPosts.getInstance(this);
presenter.updateListaRecycler( p );
}
}
...

 

Assim podemos partir para os testes finais desta primeira parte de estudo com o FCM.

Testes e resultados com a nova configuração

Execute o aplicativo em seu emulador ou device de testes. Feche ele e então crie um novo post no dashboard Web de administrador:

Formulário de novo post preenchido com novos dados

Depois de receber a notificação e abrir o app por ela, abra o menu gaveta. Assim terá algo similar a imagem a seguir:

Notificação FCM sendo entregue ao app Android de blog

Agora crie um novo post, porém antes deixe o aplicativo aberto. Assim verá que esse novo post será incluído no topo da lista de posts.

Assim finalizamos essa primeira parte de explicação e aplicação do FCM com servidor de aplicativo. Foi um pouco extenso, mas busquei colocar o suficiente que já lhe permitisse terminar o estudo e assim trabalhar API em seus aplicativos Android.

Não esqueça de assinar a 📫 lista de e-mails do Blog logo ao lado do artigo (ou abaixo, no final dele). E também de se inscrever canal no YouTube: Thiengo [Calopsita].

Vídeo com a implementação do projeto de exemplo

A seguir o vídeo com a implementação do FCM no projeto Android e Web:

Para acesso aos conteúdos completos do projeto de Blog, incluindo o back-end Web, entre nos seguintes GitHub:

Conclusão

Trabalhar com notificação push no Android já é algo bem comum, algumas vezes precisamos de funcionalidades simples, somente para reengajamento de usuários.

Para isso temos algumas APIs que podem ser implementadas em poucos minutos e são ainda mais fáceis de utilizar, uma de exemplo é a API OneSignal.

Porém em alguns domínios de problema temos de ter um maior controle, juntando a isso: as notificações devem ser criadas de acordo com ações dos usuários do aplicativo.

Para esses tipos de domínios nós podemos utilizar o Firebase Cloud Messaging (FCM) na versão com servidor de aplicativo. Mesmo que o código não seja tão trivial como o de uma API como o OneSignal, ele é ainda viável tendo em mente o ganho de funcionalidade que teremos em nosso app.

Mesmo com o Google Cloud Messaging (GCM) ainda funcionando, o Google fortemente recomenda que seja utilizado o FCM para trabalho com notificações push.

Algo importante a estudar quando trabalhando com notificações via FCM são os limites dessa tecnologia. Digo isso, pois há alguns, onde os principais são: tamanho de dados, em bytes, na mensagem; quantidade de tokens por envio; não entrega da mensagem quando o device está em modo soneca.

No próximo artigo trabalharemos mais contextos com esse mesmo projeto FCM.

Deixe abaixo o seu comentário ou dúvida e se inscreva na 📩 mailing list do Blog para receber em primeira mão os conteúdos sobre desenvolvimento Android.

Abraço.

Fontes

Adicionar o Firebase ao projeto Android

Configurar um aplicativo cliente do Firebase Cloud Messaging no Android

Implementação do protocolo de servidor de conexão

Envio de mensagens descendentes

Firebase Cloud Messaging Protocolo HTTP

Investir em Você é Barra de Ouro a R$ 2,00. Cadastre-se e receba grátis conteúdos Android sem precedentes!
Email inválido

Relacionado

Como Construir Aplicativos Android Com HTML e JSOUPComo Construir Aplicativos Android Com HTML e JSOUPAndroid
Construindo a Política de Privacidade de Seu Aplicativo Android [Agora Obrigatório]Construindo a Política de Privacidade de Seu Aplicativo Android [Agora Obrigatório]Android
FlexboxLayout Para Um Design Previsível No AndroidFlexboxLayout Para Um Design Previsível No AndroidAndroid
Como Utilizar Spannable no Android Para Customizar StringsComo Utilizar Spannable no Android Para Customizar StringsAndroid

Compartilhar

Comentários Facebook

Comentários Blog (32)

Para código / script, coloque entre [code] e [/code] para receber marcação especifica.
Forneça seu nome válido.
Forneça seu email válido.
Forneça o comentário.
Enviando, aguarde...
wagner (1) (0)
15/01/2019
Boa tarde Thiengo.

Pode me explicar quais classes eu posso tirar caso meu APP use autenticação própria? Não qro usar a autenticação do facebook. Valeu
Responder
Vinícius Thiengo (0) (0)
05/02/2019
Wagner, tudo bem?

Não há necessidade de remover nenhuma classe. Você poderá continuar com as classes PresenterLogin e LoginActivity.

Porém os algoritmos da AccountKit API na LoginActivity deverão removidos para darem lugar aos seus próprios códigos de login.

Abraço.
Responder
Leandro (1) (0)
26/12/2018
Boa noite Thiengo.
Primeiramente parabéns pelo seu artigo, excelente publicação.
Eu estou implementando a primeira parte deste projeto e logo no início quando copiei os arquivos do backend e coloquei para rodar, ao tentar me autenticar no backend o botão "Entrar" ficar com o nome "Verificando" e não segue para o dashboard, ae quando eu atualizo a página ele vai para o dashboard corretamente. A conexão com o banco está correta, pois fiz uma teste para criar post e os dados foram inseridos no banco. Poderia me ajudar?
Responder
Vinícius Thiengo (0) (0)
27/12/2018
Leandro, tudo bem?

Eu precisaria de um debug seu. Faça o seguinte, no código back-end, nos condicionais do CtrlAdmin.php, coloque todos eles para também responderem a chamadas GET ($_GET no PHP).

Dessa forma, utilizando a barra de endereço do navegador, tente realizar o login, colocando as variáveis corretas na URL.

Alias, antes de tentar o login via barra de endereço, descimente no arquivo CtrlAdmin.php a linha que contém ini_set('display_errors', 1);

Assim será possível ver os problemas que estão ocorrendo no retorno do código de login e consequentemente corrigi-los.

Obs. : o CtrlAdmin.php é o seguinte: https://github.com/viniciusthiengo/blog-android-app-web/blob/master/ctrl/CtrlAdmin.php

Abraço.
Responder
04/07/2018
Bom dia Thiengo.... como sempre é impossível dar uma nota para seus artigos.... todos maravilhosos e qualquer nota seria, com certeza, abaixo do que merece....
Tenho um site de notícias feito com o joomla (banco de dados Mysql) - http://www.infobnews.com.br (se puder dar uma olhada no site, agradeço....)....
Estou fazendo o app para o site e quero que os usuários cadastrados através da página web joomla, recebam as notificações quando um novo artigo é acrescentado....
Acredita que estou me batendo com isso???? não sei nem te explicar o problema, mas tem a ver com o momento em que deve existir a comunicação com o banco de dados.... já teve alguém com o mesmo problema utilizando joomla????
Fico no aguardo....
Abraços e novamente obrigado.... você é fera....
Responder
Vinícius Thiengo (0) (0)
08/07/2018
Aroldo, tudo bem?

Show de bola que você curti os conteúdos aqui do Blog, eles continuarão sendo postados semanalmente.

Sobre o problema... o Joomla, se me recordo bem, tem o backend em PHP, certo? Sendo assim você pode aproveitar boa parte do projeto deste artigo.

Isso, pois o que você precisa é o modelo simples de uso do FCM, onde não é necessário, por exemplo, a identificação de cada usuário para envio de mensagem especifica.

Seu projeto precisará sim de tokens únicos, porém no momento de envio da mensagem de "novo artigo" poderá obter todos, de uma única tabela, e então enviar de 1000 em 1000, isso para poder respeitar os limites de envio do FCM.

O momento de envio é justamente depois do artigo ter sido salvo na base de dados MySQL, salvo como "artigo ativo", ou seja, público no site.

O que eu faria, seria:

-> Criar uma nova tabela para conter todos os tokens únicos enviados do aplicativo Android;

-> Criar uma nova coluna na mesma tabela de artigos do Joomla. Coluna responsável por salvar se já houve ou não o envio de notificação. O conteúdo dessa coluna será importante, pois artigos que foram criados como "inativos" não devem ter notificações enviadas;

-> Colocaria o código FCM logo depois do código que retorna o "Ok" de que o novo artigo foi salvo. Esse código, além dos algoritmos FCM (incluindo o algoritmo de obtenção de tokens únicos presentes na nova tabela), teria de ter um algoritmo de verificação em tabela de artigo se a notificação para este novo artigo já foi ou não enviada e se ele está "ativo". Caso o artigo esteja "ativo" e ainda não tenha tido as notificações enviadas, então as notificações seriam enviadas (de 1000 em 1000 usuários) e ao final haveria a atualização do valor da nova coluna em tabela de artigo, atualização para indicar que já houve o envio de notificações;

-> O código do item anterior deverá também estar presente logo depois do algoritmo de atualização de artigo, pois alguns artigos criados como "inativos" poderão ser atualizados como "ativos", posteriormente.

O roteiro acima é somente para o caso que seja necessário que todo o processo de envio de notificação seja automático.

Aroldo, de qualquer forma, estude a possibilidade de utilizar o OneSignal, mesmo sabendo que o cliente terá de passar por alguns 2-3 dias de treinamento.

Digo isso, pois com as atualizações do Joomla o código de notificação presente no back-end PHP terá de ser refeito.

Mais sobre o OneSignal, no link a seguir: https://www.thiengo.com.br/onesignal-para-notificacoes-em-massa-no-android

Não deixe de também visitar o site oficial do OneSignal, pois o conteúdo link acima está um pouco antigo.

Abraço.
Responder
09/07/2018
Valeu pela resposta Thiengo....
Estarei implantando essas formas que falou e, depois, dou um feedback aqui para que - se alguém tiver a mesma dúvida - saber os passos....
Parabéns pelo teu trabalho e disposição em auxiliar os outros....
Abraços....
Responder
25/10/2017
Oi Thiengo tudo bem ?

Meu projeto, quando coloco meu email e senha igual ao do banco de dados, aparece a mensagem: "verificando" e não sai dessa tela.
O q pode ser ?
Responder
Vinícius Thiengo (0) (0)
30/10/2017
Nivaldo, tudo bem aqui.

Criou a sua própria base de dados como informado em artigo?

Provavelmente terá de realizar alguns testes com o seu backend.

- Em CtrlUser.php descomente a linha com o código ini_set('display_errors', 1);

- Logo depois, na área de definição de URL em seu navegador, coloque o caminho de acesso ao CtrlUser.php de seu servidor e também, como variável GET, os dados de login. Exemplo:

?/CtrlUser.php?metodo=login&email=seu_email@gmail.com&password=sua_senha

- Com isso terá o retorno do error que está ocorrendo. Utilize o email e senha registrados no banco de dados como indicado em artigo.

Nivaldo, assim saberá o erro, caso não consiga corrigi-lo, volte aqui com ele por completo que lhe ajudo com isso.

Fique ciente que depois de corrigir o erro, deverá voltar a comentar a linha ini_set('display_errors', 1);

Abraço.
Responder
31/10/2017
Oi Thiengo tudo bem ?

A linha  ini_set('display_errors', 1);
já estava sem comentário.

Coloquei o email e senha e apenas exibiu a página de login.
http://www.vantagemsites.com.br/blog/?/CtrlUser.php?metodo=login&email= nivaldo.de4409@gmail.com&password=Mf0u9q0f0

O q pode ser ?
Responder
Vinícius Thiengo (0) (0)
31/10/2017
Nivaldo,

Depois da linha, em CtrlUser.php:

$dados = isset($_POST['metodo']) ? $_POST : $_GET;

Coloque o seguinte trecho de código:

var_export($dados, true);
exit;

Somente para ver se algo está entrando em $dados.

Depois volte aqui com a resposta.

Abraço.
Responder
31/10/2017
Oi.http://www.vantagemsites.com.br/blog/ctrl/CtrlUser.php?metodo=login&email= nivaldo.de4409@gmail.com&password=Mf0u9q0f0 com esta linha: var_export($dados, true); exit; fica página em branco. comentando ela mostra:


Fatal error:  Call to a member function prepare() on null in /home/vantagem/public_html/blog/cgd/CgdUser.php on line 24

a tabela ba_user está vazia, é isso mesmo ? no aguardo. Obrigado.
Responder
Vinícius Thiengo (0) (0)
31/10/2017
Nivaldo,

Certifique-se de que há o banco de dados ?blog_android? ou o banco equivalente que você criou para prosseguir com os algoritmos do projeto de exemplo do artigo desta página.

Confirme também, depois de certificado que o banco de dados existe, se é possível realizar a conexão ao banco utilizando os dados de conexão que você colocou na classe Database.php de seu projeto de exemplo. Classe como à seguir:

https://github.com/viniciusthiengo/blog-android-app-web/blob/master/cgd/Database.php

Nivaldo, corrija o que estiver errado e então tente prosseguir. Caso ainda persista problemas, volte aqui.

Abraço.
Responder
31/10/2017
Oi Thiengo tudo bem ? Meu arquivo Database.php está assim:
pdo = null;
}


public function getConn()
{
if( is_object($this->pdo) ){
return($this->pdo);
}

try{
$this->pdo = new PDO(
sprintf(
'%s:host=%s;dbname=%s;port=%s;charset=%s',
'mysql',
'127.0.0.1',
'vantagem_blog_android', //acrescentei o vantagem_
'8889',
'utf8'),
'root',
'root' );
}
catch(PDOException $e){}
return($this->pdo);
}
}
O 127.0.0.1 precisa mudar ? no aguardo e obrigado.
Responder
Vinícius Thiengo (0) (0)
01/11/2017
Nivaldo, está conseguindo conectar ao banco de dados utilizando essas dados de conexão?

Digo, conectando até mesmo utilizando um software somente para isso, como o MySQL Workbench?

Caso não, certifique-se de que o nome do banco de dados está correto, a senha, o usuário e porta estão corretos e então, certificando-se de que está tudo ok, tente a conexão utilizado: localhost ou http://localhost no lugar de 127.0.0.1

Se não rodar, tente com o IP interno de sua máquina, o IP disponibilizado pelo seu roteador.

Nivaldo, veja se assim vai sem problemas.

Abraço.
Responder
02/11/2017
Oi Thiengo tudo bem ?
pdo = null;
}
 
 
public function getConn()
{
if( is_object($this->pdo) ){
return($this->pdo);
}
 
try{
$this->pdo = new PDO(
sprintf(
'%s:host=%s;dbname=%s;port=%s;charset=%s',
'mysql',
'127.0.0.1',
'vantagem_blog_android',
'8889',
'utf8'),
'root',
'root' );
}
catch(PDOException $e){}
return($this->pdo);
}
}
Não está faltando 1 parâmetro ? No lugar do 'mysql' devo por a minha senha do banco de dados ? Meu site é: http://www.vantagemsites.com.br  devo usar localhost ? Como descobrir a porta ? Obrigado. No aguardo.
Responder
Vinícius Thiengo (0) (0)
06/11/2017
Nivaldo, tudo bem aqui.

Essas informações todas você provavelmente obtém somente no dashboard de usuário da empresa de servidor que contratou.

A Locaweb, por exemplo, me permite mudar somente a senha, todos os outros parâmetros (nome da base, porta, e URI) são fornecidos pela empresa.

Abraço.
Responder
Deyvisson (1) (0)
15/07/2017
Thiengo. Boa tarde. Cara fiz uma implementação no back-end com jax-rs. Consigo enviar a solicitação para o FCM que me retorna que está tudo ok. Porém, em minha app só recebo a notificação se ela estiver aberta. Não consigo receber as notificações com a app fechada, nem enviando diretamente do Firebase Console. Poderia me dar uma ajudar? Obrigado.
Responder
Vinícius Thiengo (0) (0)
16/07/2017
Deyvisson, tudo bem?

Se possível, teste novamente com o aplicativo fechado e verifique se nos logs do Android Studio é apresentada alguma mensagem de error ou de warning.

Digo isso, pois em meus testes, mesmo com o app fechado, fora do background, a mensagem era entregue.

Você pode colocar um LogCat na implementação de FirebaseMessagingService, assim é possível confirmar se realmente a mensagem não está chegando.

Note que se o device for um com o Android 6 ou superior e os testes de aplicativo fechado estiverem ocorrendo quando o device está em modo soneca, realmente as notificações não serão entregues de imediato, isso acontecerá somente na janela de processamento liberado do device em modo soneca.

Mais sobre esse assunto, Dos Mode, no link a seguir: http://www.thiengo.com.br/gcmnetworkmanager-para-execucao-de-tarefas-no-background-android#title-1

Abraço.
Responder
melquisedequelima (1) (0)
10/06/2017
muito bom. excelente conteúdo.
Responder
Carlos (1) (0)
12/05/2017
Boas Vinícius Thiengo,

Eu ja vi os dois videos e consegui por tudo a funcionar, mas estou com um probleminha, se eu não for no banco de dados e colocar o endereço de imagem a mão e deixar ele vazio quando a app vai iniciar ela para e da um exception no postactivity na parte em que usa a Library Picasso pois a condição que tem la para dar return se o presenter.getUser().getUriImagem() == null não esta funcionando. O que você acha disso?
Cumprimentos
Responder
Vinícius Thiengo (0) (0)
12/05/2017
Carlos, tudo bem?

Você está certo, ainda tem um bug nesse condicional de verificação do objeto User, na verdade está faltando uma verificação.

Quando são obtidos os dados do backend Web, aqueles que não existem no banco de dados estão sendo tratados como vazios e não como nulos. Então um condicional correto seria o seguinte:

if( presenter.getUser().getUriImagem() == null ||
        presenter.getUser().getUriImagem().isEmpty() ){

        return;
}

Nem mesmo as verificações de nome e profissão são necessárias.

Faça essa atualização em seu projeto que deve funcionar sem problemas. Testei aqui e atualizei o GitHub do projeto. Abraço.
Responder
Carlos (1) (0)
12/05/2017
Obrigado Vinícius Thiengo,

Ficou a funcionar ;-)
Responder
06/05/2017
Gerei a senha no banco tudo certo porém qndo tento acessar recebo este erro:
Fatal error: Uncaught Error: Call to a member function prepare() on null in /Applications/XAMPP/xamppfiles/htdocs/blog-android-app-web-master/cgd/CgdPost.php:143 Stack trace: #0 /Applications/XAMPP/xamppfiles/htdocs/blog-android-app-web-master/apl/AplPost.php(57): CgdPost->getCategorias() #1 /Applications/XAMPP/xamppfiles/htdocs/blog-android-app-web-master/index.php(10): AplPost->getCategorias() #2 {main} thrown in /Applications/XAMPP/xamppfiles/htdocs/blog-android-app-web-master/cgd/CgdPost.php on line 143
Responder
Vinícius Thiengo (1) (0)
07/05/2017
Paulo, tudo bem?

Aparentemente o problema está na criação do objeto de conexão em Database, método getConn().

Está ao menos conseguindo realizar o login?

Certifique-se de ter criado todas as tabelas no banco de dados e de ter colocado os valores iniciais em algumas, como indico na seção do link a seguir: http://www.thiengo.com.br/fcm-android-dominio-do-problema-implementacao-e-testes-com-servidor-de-aplicativo-parte-1#title-04

Caso nem mesmo o login esteja contecendo, verifique se todos os dados de conexão estão corretos. Pois a porta que utilizo aqui, 8889, não é comum para conexão com o MySQL em ambientes de desenvolvimento.

Na parte dois desta série eu comento sobre alguns problemas que deixei passar na parte um, se possível estude ela também em: http://www.thiengo.com.br/fcm-android-relatorio-e-notificacao-por-topicos-parte-2

Destrinche o método getCategoria() e tabela tb_categoria, caso o problema persista depois de algumas atualizações e certificado de que o banco realmente está funcional. Se mesmo assim nada, volte aqui que buscaremos um outro caminho para prosseguir com a solução. Abraço.
Responder
Leandro (1) (0)
02/05/2017
Boa noite Thiengo estou com a dificuldade de acessar mesmo gerando o rash e
com o seguinte erro
Undefined variable: menu in D:\xamp\htdocs\blog-android-app-web-master\view\dashboard.php on line 33
Responder
Vinícius Thiengo (0) (0)
02/05/2017
Leandro, tudo bem?

Se possível, coloque no PasteBin (https://pastebin.com/ ) os códigos de seus arquivos: /index.php, /view/dashboard.php e /view/menu.php.

Logo depois compartilhe os links gerados aqui, pois assim poderei ver o que está ocorrendo. Abraço.
Responder
Felipe (1) (1)
26/04/2017
Estou com dificuldades em rodar o projeto do servidor, migrei o banco, configurei a IDE, ele até mostra a tela de login, mas na hora de logar ele não vai.
Alguem poderia me ajudar.

Boa noite :)
Responder
Vinícius Thiengo (0) (0)
26/04/2017
Felipe, tudo bem?

Chegou a gerar a senha utilizando o algoritmo:

password_hash( "sua_senha", PASSWORD_DEFAULT, ['cost'=>12] );

E depois colocando o resultado no campo password da tabela ba_user_system? Coloque também o seu email. Logo depois tente o login com a senha utilizada no algoritmo anterior, por exemplo: "sua_senha".

Já tentou assim e ainda nada de funcionar? Outra coisa, certificou-se de que o banco e as tabelas foram criadas sem problemas, certo? Abraço.
Responder
Felipe (1) (0)
27/04/2017
Bom dia thiengo, gerei a senha por aqui https://php-password-hash-online-tool.herokuapp.com/password_hash , setei o email tbm e nada.
As tabelas e campos estão criadas certinhas no banco tbm.
Responder
Vinícius Thiengo (0) (0)
27/04/2017
Felipe, como conheço pouco esse site e assumindo que você obteve o resultado dele e o colocou na coluna password do registro de usuário admin.

Sabendo disso, tente gerar o password como informei no comentário anterior. No arquivo index.php de seu projeto você pode fazer a seguinte alteração logo no topo do arquivo:

$password = password_hash( "sua_senha", PASSWORD_DEFAULT, ['cost'=>12] );
exit( $password );

Então copie o que será impresso no navegador e cole na coluna password do registro de seu usuário de sistema do dashboard do projeto deste artigo.

Note que assim é certo de funcionar, pois os algoritmo de verificação de senha está assumindo que ela foi gerada pelo método nativo password_hash(). Por isso você está tendo problemas com a senha.

Depois de gerar a senha com o código indicado anteriormente, remova ele, caso contrário seu index.php não funcionará como previsto.

Falo mais sobre essa geração de senha na seção do artigo, do link a seguir: http://www.thiengo.com.br/fcm-android-dominio-do-problema-implementacao-e-testes-com-servidor-de-aplicativo-parte-1#title-04

Para ter certeza também de que o problema não está na maneira como seus algoritmos tratam um usuário logado, logo no topo do index.php você pode colocar o seguinte código:

$_SESSION[ User::ID_KEY ] = /* AQUI VC COLOCA O ID DO REGISTRO DO USER ADMIN EM SEU BANCO DE DADOS */

Assim o dashboard de usuário logado deve ser aberto.

Abraço.
Responder
Felipe (1) (0)
27/04/2017
Boa noite Thiengo, deu certo, obrigado msmo =)
Responder